diff --git a/.bumpversion.toml b/.bumpversion.toml index 3d57dc0fe..c28f65ec1 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.38.0-beta.12" +current_version = "0.40.0-beta.1" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index eee966f76..d625b0698 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -44,3 +44,27 @@ updates: python-deps: patterns: - "*" + + # The npm ecosystem covers pnpm lockfiles. There are two separate installs: + # the bindings themselves and the examples, which have their own lockfile. + # As with cargo and pip above, only bump the lockfile — the version ranges + # in package.json are our consumers' constraints, not ours. + - package-ecosystem: npm + directory: /nodejs + schedule: + interval: weekly + versioning-strategy: lockfile-only + groups: + nodejs-deps: + patterns: + - "*" + + - package-ecosystem: npm + directory: /nodejs/examples + schedule: + interval: weekly + versioning-strategy: lockfile-only + groups: + nodejs-examples-deps: + patterns: + - "*" diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index f77ba5f77..eac4cc4fc 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -29,12 +29,14 @@ jobs: steps: - uses: actions/setup-node@v6 with: - node-version: "18" + node-version: "24" + - uses: pnpm/action-setup@v6 + with: + version: 11.1.1 # These rules are disabled because Github will always ensure there # is a blank line between the title and the body and Github will # word wrap the description field to ensure a reasonable max line # length. - - run: npm install @commitlint/config-conventional - run: > echo 'module.exports = { "rules": { @@ -43,7 +45,11 @@ jobs: "body-leading-blank": [0, "always"] } }' > .commitlintrc.js - - run: npx commitlint --extends @commitlint/config-conventional --verbose <<< $COMMIT_MSG + - run: > + pnpm dlx + --package @commitlint/cli@21.2.2 + --package @commitlint/config-conventional@21.2.2 + commitlint --extends @commitlint/config-conventional --verbose <<< $COMMIT_MSG env: COMMIT_MSG: > ${{ github.event.pull_request.title }} @@ -54,7 +60,7 @@ jobs: with: script: | const message = `**ACTION NEEDED** - + Lance follows the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) for release automation. The PR title and description are used as the merge commit message.\ diff --git a/.github/workflows/docs-link-check.yml b/.github/workflows/docs-link-check.yml index 1286819bc..afa48a14a 100644 --- a/.github/workflows/docs-link-check.yml +++ b/.github/workflows/docs-link-check.yml @@ -56,7 +56,7 @@ jobs: uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 with: # Restricted to http(s) on purpose. Much of docs/src is generated - # API reference (the js/ tree comes from `npm run docs` in nodejs) + # API reference (the js/ tree comes from `pnpm run docs` in nodejs) # and the hand-written pages use mkdocstrings cross-references and # nav-relative paths that only resolve in the site mkdocs builds, # not in this checkout, so relative links would be reported as diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e787afb7f..d0ec583bf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -55,9 +55,7 @@ jobs: - name: Set up node uses: actions/setup-node@v6 with: - node-version: 20 - cache: 'npm' - cache-dependency-path: docs/package-lock.json + node-version: 24 - name: Install node dependencies working-directory: nodejs run: | diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index e82cf71c9..98acccf02 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -47,9 +47,8 @@ jobs: version: 11.1.1 - uses: actions/setup-node@v6 with: - # pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL - # in October. The library itself still supports Node >= 18 - # (see test matrix below). + # Build on a supported LTS; the matrix job below covers every + # Node version the library claims to support. node-version: 24 cache: 'pnpm' cache-dependency-path: nodejs/pnpm-lock.yaml @@ -84,7 +83,7 @@ jobs: timeout-minutes: 30 strategy: matrix: - node-version: [ "18", "20" ] + node-version: [ "22", "24", "26" ] runs-on: "ubuntu-22.04" defaults: run: @@ -101,9 +100,9 @@ jobs: - uses: actions/setup-node@v6 name: Setup Node.js 24 for build with: - # pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL - # in October. Build/install runs on Node 24; tests run on the - # matrix version below using direct jest invocation. + # Build and install once on a fixed version so the generated docs + # are identical across matrix legs; the tests below then run on each + # supported Node version. node-version: 24 cache: 'pnpm' cache-dependency-path: nodejs/pnpm-lock.yaml @@ -152,9 +151,9 @@ jobs: S3_TEST: "1" # Newer @smithy/core uses dynamic ESM imports. NODE_OPTIONS: "--experimental-vm-modules" - # Invoke jest directly because pnpm 11 itself requires Node 22+ - # while the matrix tests on older Node versions. - run: npx jest --verbose + # Invoke the installed jest binary directly; the pnpm shim is set up + # against the build-phase Node, not the version selected above. + run: node_modules/.bin/jest --verbose - name: Test examples working-directory: ./ env: @@ -164,7 +163,7 @@ jobs: run: | python ci/mock_openai.py & cd nodejs/examples - npx jest --testEnvironment jest-environment-node-single-context --verbose + node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose macos: timeout-minutes: 30 # macos-15 ships a newer linker; the older macos-14 linker fails to insert @@ -185,8 +184,7 @@ jobs: version: 11.1.1 - uses: actions/setup-node@v6 with: - # pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL - # in October. + # pnpm 11 requires Node >= 22.13. node-version: 24 cache: 'pnpm' cache-dependency-path: nodejs/pnpm-lock.yaml @@ -209,4 +207,4 @@ jobs: pnpm tsc - name: Test run: | - pnpm test + pnpm test --runInBand diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index ee6906e00..c3724de8d 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -168,8 +168,7 @@ jobs: - name: Setup node uses: actions/setup-node@v6 with: - # pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL - # in October. + # pnpm 11 requires Node >= 22.13. node-version: 24 cache: pnpm cache-dependency-path: nodejs/pnpm-lock.yaml @@ -251,7 +250,7 @@ jobs: run: | set -e ${{ matrix.settings.pre_build }} - npx napi build --platform --release \ + node_modules/.bin/napi build --platform --release \ --features ${{ matrix.settings.features }} \ --target ${{ matrix.settings.target }} \ --dts ../lancedb/native.d.ts \ @@ -271,7 +270,7 @@ jobs: - name: Build run: | ${{ matrix.settings.pre_build }} - npx napi build --platform --release \ + node_modules/.bin/napi build --platform --release \ --features ${{ matrix.settings.features }} \ --target ${{ matrix.settings.target }} \ --dts ../lancedb/native.d.ts \ @@ -339,7 +338,7 @@ jobs: - target: aarch64-unknown-linux-gnu host: ubuntu-2404-8x-arm64 node: - - '20' + - '22' runs-on: ${{ matrix.settings.host }} defaults: run: @@ -385,9 +384,9 @@ jobs: - name: Move built files run: cp dist/native.d.ts dist/native.js dist/*.node lancedb/ - name: Test bindings - # Invoke jest directly because pnpm 11 itself requires Node 22+ - # while the matrix tests on older Node versions. - run: npx jest --verbose + # Invoke the installed jest binary directly; the pnpm shim is set up + # against the install-phase Node, not the version selected above. + run: node_modules/.bin/jest --verbose publish: name: Publish runs-on: ubuntu-latest diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index db8919ddc..3594bd826 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -156,7 +156,7 @@ jobs: - name: Test without pylance or pandas run: | pip uninstall -y pylance pandas - pytest -vv python/tests/test_table.py + pytest -vv python/tests/test_table.py python/tests/test_namespace_no_pylance.py # Make sure wheels are not included in the Rust cache - name: Delete wheels run: rm -rf target/wheels diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index cd872621b..4ac2c3070 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -232,7 +232,10 @@ jobs: ALL_FEATURES=`cargo metadata --format-version=1 --no-deps \ | jq -r '.packages[] | .features | keys | .[]' \ | grep -v s3-test | sort | uniq | paste -s -d "," -` - cargo test --profile ci --features $ALL_FEATURES --locked + # Run doctests before test binaries fill the runner disk. Examples are + # already built by the Linux job, so avoid retaining them here. + cargo test --profile ci --features $ALL_FEATURES --locked --doc + cargo test --profile ci --features $ALL_FEATURES --locked --lib --tests windows: strategy: diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml new file mode 100644 index 000000000..70e96efe5 --- /dev/null +++ b/.github/workflows/typos.yml @@ -0,0 +1,20 @@ +name: Typo checker +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + run: + name: Spell Check with Typos + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Check spelling of the entire repository + uses: crate-ci/typos@6802cc60d4e7f78b9d5454f6cf3935c042d5e1e3 # v1.26.0 diff --git a/.github/workflows/update_package_lock_run.yml b/.github/workflows/update_package_lock_run.yml deleted file mode 100644 index 35836a86f..000000000 --- a/.github/workflows/update_package_lock_run.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Update package-lock.json - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: main - persist-credentials: false - fetch-depth: 0 - lfs: true - - uses: ./.github/workflows/update_package_lock - with: - github_token: ${{ secrets.LANCEDB_RELEASE_TOKEN }} diff --git a/.github/workflows/update_package_lock_run_nodejs.yml b/.github/workflows/update_package_lock_run_nodejs.yml deleted file mode 100644 index 227a94ecc..000000000 --- a/.github/workflows/update_package_lock_run_nodejs.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Update NodeJs package-lock.json - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: main - persist-credentials: false - fetch-depth: 0 - lfs: true - - uses: ./.github/workflows/update_package_lock_nodejs - with: - github_token: ${{ secrets.LANCEDB_RELEASE_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bef53f90e..6b863aebb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,6 +10,10 @@ repos: rev: v0.9.9 hooks: - id: ruff + - repo: https://github.com/crate-ci/typos + rev: v1.26.0 + hooks: + - id: typos # - repo: https://github.com/RobertCraigie/pyright-python # rev: v1.1.395 # hooks: @@ -20,7 +24,10 @@ repos: hooks: - id: local-biome-check name: biome check - entry: npx @biomejs/biome@1.8.3 check --config-path nodejs/biome.json nodejs/ + # Use the biome from nodejs/package.json rather than a separately + # pinned one: the two drifted apart and disagreed on formatting, so + # this hook rejected code that `pnpm lint` accepted. + entry: nodejs/node_modules/.bin/biome check --config-path nodejs/biome.json nodejs/ language: system types: [text] files: "nodejs/.*" diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 000000000..7d1e4b14b --- /dev/null +++ b/.typos.toml @@ -0,0 +1,19 @@ +[default] +extend-ignore-re = ["(?Rm)^.*(#|//)\\s*spellchecker:disable-line$"] + +[default.extend-words] +# Azure Kubernetes Service, mentioned in rust/lancedb/src/remote/oauth.rs. +AKS = "AKS" +# RabitQ is the name of a vector quantization algorithm, not a typo of "Rabbit". +Rabit = "Rabit" +# `VarBuilder::from_mmaped_safetensors` is the real (if oddly-spelled) name of +# the candle-core API we call in rust/lancedb/src/embeddings/sentence_transformers.rs. +mmaped = "mmaped" +# `WriteableBuffer` is the real name of a type from Python's `_typeshed` stubs, +# used in python/python/lancedb/_blob.py. +Writeable = "Writeable" + +[files] +extend-exclude = [ + "*_THIRD_PARTY_LICENSES.*", +] diff --git a/AGENTS.md b/AGENTS.md index 1e072446a..f6d01db03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Before committing changes, run formatting for every language you touched. At min * Rust changes: run `cargo fmt --all`. * Python changes: run `ruff format .` and `ruff check .` from the repository root, and run targeted tests through `cd python && uv run ...`. -* TypeScript changes: run the relevant `npm`/`pnpm` lint, format, build, and docs commands in `nodejs`. +* TypeScript changes: run the relevant `pnpm` lint, format, build, and docs commands in `nodejs`. Before creating a PR, the exact value passed to `gh pr create --title` must follow Conventional Commits, such as `fix: support nested field paths in native index creation` @@ -101,12 +101,12 @@ Python bindings changes: TypeScript bindings changes: 1. Add napi-rs method binding on `Table` in `nodejs/src/table.rs`. -2. Run `npm run build` to generate TypeScript definitions. +2. Run `pnpm build` to generate TypeScript definitions. 3. Add typescript method on abstract class `Table` in `nodejs/src/table.ts`. 4. Add concrete method on `LocalTable` class in `nodejs/src/native_table.ts`. * Note: despite the name, this class is also used for remote tables. 5. Add test in `nodejs/__test__/table.test.ts`. -6. Run `npm run docs` to generate TypeScript documentation. +6. Run `pnpm run docs` to generate TypeScript documentation. ## Python API reference diff --git a/Cargo.lock b/Cargo.lock index cff38a304..0d6a28878 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,6 +332,34 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrow-flight" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2dbe34824c639e43136af8f106992792ab456540d54b880bc320a3192502d2e" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ipc", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", + "base64 0.22.1", + "bytes", + "futures", + "once_cell", + "paste", + "prost", + "prost-types", + "tonic", + "tonic-prost", +] + [[package]] name = "arrow-ipc" version = "58.4.0" @@ -535,9 +563,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -553,6 +581,12 @@ dependencies = [ "loom", ] +[[package]] +name = "asyncband" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" + [[package]] name = "atoi" version = "2.0.0" @@ -624,9 +658,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -635,14 +669,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -775,7 +810,7 @@ dependencies = [ "http 0.2.12", "http 1.5.0", "http-body 1.1.0", - "lru 0.16.4", + "lru", "percent-encoding", "regex-lite", "sha2 0.11.0", @@ -909,7 +944,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "http-body-util", - "md-5 0.11.0", + "md-5", "pin-project-lite", "sha1 0.11.0", "sha2 0.11.0", @@ -970,7 +1005,7 @@ dependencies = [ "hyper-util", "pin-project-lite", "rustls 0.21.12", - "rustls 0.23.40", + "rustls 0.23.45", "rustls-native-certs", "rustls-pki-types", "tokio", @@ -1075,9 +1110,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.4.8" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" +checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9" dependencies = [ "base64-simd", "bytes", @@ -1129,7 +1164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.4.5", "bytes", "futures-util", "http 1.5.0", @@ -1138,7 +1173,7 @@ dependencies = [ "hyper 1.9.0", "hyper-util", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -1156,6 +1191,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.4.5" @@ -1177,6 +1237,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "backoff" version = "0.4.0" @@ -1443,9 +1521,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] @@ -1696,7 +1774,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", - "clap_derive", ] [[package]] @@ -1705,22 +1782,8 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", "anstyle", "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", ] [[package]] @@ -1874,9 +1937,9 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" dependencies = [ "unicode-segmentation", ] @@ -2159,16 +2222,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "ctor" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - [[package]] name = "ctor" version = "1.0.12" @@ -2179,12 +2232,6 @@ dependencies = [ "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "ctutils" version = "0.4.2" @@ -2212,12 +2259,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -2236,15 +2283,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -2260,13 +2307,13 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -2321,7 +2368,7 @@ dependencies = [ "indexmap 2.14.0", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", "parking_lot", "sqlparser 0.62.0", "tempfile", @@ -2350,7 +2397,7 @@ dependencies = [ "futures", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", "parking_lot", "tokio", ] @@ -2375,7 +2422,7 @@ dependencies = [ "futures", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", ] [[package]] @@ -2395,7 +2442,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", - "object_store", + "object_store 0.13.2", "sqlparser 0.62.0", "tokio", "uuid", @@ -2436,7 +2483,7 @@ dependencies = [ "glob", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", "parking_lot", "rand 0.9.5", "tokio", @@ -2463,7 +2510,7 @@ dependencies = [ "datafusion-session", "futures", "itertools 0.14.0", - "object_store", + "object_store 0.13.2", "tokio", ] @@ -2485,7 +2532,7 @@ dependencies = [ "datafusion-physical-plan", "datafusion-session", "futures", - "object_store", + "object_store 0.13.2", "regex", "tokio", ] @@ -2508,7 +2555,7 @@ dependencies = [ "datafusion-physical-plan", "datafusion-session", "futures", - "object_store", + "object_store 0.13.2", "tokio", "tokio-stream", ] @@ -2534,7 +2581,7 @@ dependencies = [ "datafusion-physical-expr-common", "futures", "log", - "object_store", + "object_store 0.13.2", "parking_lot", "rand 0.9.5", "tempfile", @@ -2598,7 +2645,7 @@ dependencies = [ "hex", "itertools 0.14.0", "log", - "md-5 0.11.0", + "md-5", "memchr", "num-traits", "rand 0.9.5", @@ -3037,6 +3084,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -3066,21 +3123,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -3109,6 +3151,16 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools 0.11.0", + "num-traits", +] + [[package]] name = "ecdsa" version = "0.14.8" @@ -3408,6 +3460,12 @@ dependencies = [ "rand_distr 0.5.1", ] +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "fnv" version = "1.0.7" @@ -3447,6 +3505,16 @@ version = "1.20260804.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82eb03a32a1d50555353c85a7b9d3279a6f1e91af9890b789acdf544ed57c8d7" +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3455,8 +3523,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "rand 0.9.5", @@ -3724,6 +3792,129 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc1a1678e54befc9b4bcab6cd43b8e7f834ae8ea121118b0fd8c42747675b4a" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "robust", + "rstar", + "spade", +] + +[[package]] +name = "geo-traits" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7c353d12a704ccfab1ba8bfb1a7fe6cb18b665bf89d37f4f7890edcd260206" +dependencies = [ + "geo-types", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "rayon", + "rstar", + "serde", +] + +[[package]] +name = "geoarrow-array" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafe7b7de3fab1a8b7099fd6a6434ca955fa65065f9c19f0f8a133693f3c2b0e" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "geo-traits", + "geoarrow-schema", + "num-traits", + "wkb", + "wkt", +] + +[[package]] +name = "geoarrow-expr-geo" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4a62ac19c86827c6ec81ea584594b3ee96db5a8119b9774d3466c6b373c434" +dependencies = [ + "arrow-array", + "arrow-buffer", + "geo", + "geo-traits", + "geoarrow-array", + "geoarrow-schema", +] + +[[package]] +name = "geoarrow-schema" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4a7edb2a1d87024a93805332a9c8184a0354836271d42c0d18cf628a5e3cd0" +dependencies = [ + "arrow-schema", + "geo-traits", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "geodatafusion" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecbdd00d0fff2b04635c1b1e4129c217908f0c2d17539e0a2275308afce2552" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-schema", + "datafusion", + "geo", + "geo-traits", + "geoarrow-array", + "geoarrow-expr-geo", + "geoarrow-schema", + "geohash", + "thiserror 1.0.69", + "wkt", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "geohash" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f58890382f70caccc5fa388981f7ac80c913795042afce9f3e065695d8f7464" +dependencies = [ + "geo-types", + "libm", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -3813,9 +4004,9 @@ dependencies = [ [[package]] name = "goosefs-sdk" -version = "0.1.9" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1ea4eee6dcbc31b25ab4fd577adc55b677d2bed3aa3016c44c58fbe1b2298a5" +checksum = "6f521e3beacc809ced36a8aff4a468b1bf61302c8f4dca41c3f21ac0557c22ed" dependencies = [ "arc-swap", "async-trait", @@ -3824,16 +4015,9 @@ dependencies = [ "fastrand", "futures", "hostname", - "io-uring", "itoa", - "libc", - "lru 0.18.2", - "memmap2 0.9.10", - "moka", "prost", - "prost-types", "rand 0.9.5", - "reqwest 0.12.28", "serde", "thiserror 2.0.18", "tokio", @@ -3909,6 +4093,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3965,6 +4158,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -4014,18 +4217,21 @@ dependencies = [ [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" dependencies = [ + "anyhow", "async-trait", "bytes", "http 1.5.0", "more-asserts", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "uuid", "xet-client", @@ -4224,7 +4430,7 @@ dependencies = [ "http 1.5.0", "hyper 1.9.0", "hyper-util", - "rustls 0.23.40", + "rustls 0.23.45", "rustls-native-certs", "tokio", "tokio-rustls 0.26.4", @@ -4279,6 +4485,49 @@ dependencies = [ "serde", ] +[[package]] +name = "i_float" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010025c2c532c8d82e42d0b8bb5184afa449fa6f06c709ea9adcb16c49ae405b" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9190f86706ca38ac8add223b2aed8b1330002b5cdbbce28fb58b10914d38fc27" + +[[package]] +name = "i_overlay" +version = "4.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413183068e6e0289e18d7d0a1f661b81546e6918d5453a44570b9ab30cbed1b3" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", + "rayon", +] + +[[package]] +name = "i_shape" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea154b742f7d43dae2897fcd5ead86bc7b5eefcedd305a7ebf9f69d44d61082" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e6d558e6d4c7b82bc51d9c771e7a927862a161a7d87bf2b0541450e0e20915" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -4606,6 +4855,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -4815,8 +5073,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" [[package]] name = "lance" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arc-swap", "arrow", @@ -4864,7 +5122,7 @@ dependencies = [ "lance-tokenizer", "log", "moka", - "object_store", + "object_store 0.14.1", "permutation", "pin-project", "prost", @@ -4888,8 +5146,8 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "arrow-buffer", @@ -4911,7 +5169,7 @@ dependencies = [ [[package]] name = "lance-arrow-scalar" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "arrow-buffer", @@ -4925,7 +5183,7 @@ dependencies = [ [[package]] name = "lance-arrow-stats" version = "58.0.0" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "arrow-schema", @@ -4934,8 +5192,8 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrayref", "crunchy", @@ -4945,8 +5203,8 @@ dependencies = [ [[package]] name = "lance-core" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "arrow-buffer", @@ -4965,7 +5223,7 @@ dependencies = [ "log", "moka", "num_cpus", - "object_store", + "object_store 0.14.1", "pin-project", "prost", "quick_cache", @@ -4983,8 +5241,8 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow", "arrow-array", @@ -5000,9 +5258,11 @@ dependencies = [ "datafusion-functions", "datafusion-physical-expr", "futures", + "half", "jsonb", "lance-arrow", "lance-core", + "lance-geo", "log", "pin-project", "prost", @@ -5013,8 +5273,8 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow", "arrow-array", @@ -5031,8 +5291,8 @@ dependencies = [ [[package]] name = "lance-derive" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "proc-macro2", "quote", @@ -5041,8 +5301,8 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-arith", "arrow-array", @@ -5075,8 +5335,8 @@ dependencies = [ [[package]] name = "lance-file" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-arith", "arrow-array", @@ -5097,18 +5357,34 @@ dependencies = [ "lance-io", "log", "num-traits", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", + "serde", "tokio", "tracing", ] +[[package]] +name = "lance-geo" +version = "13.0.0-beta.3" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.3#565808c992c3d2adf1909b9753b50a28b6bd8a19" +dependencies = [ + "datafusion", + "geo-traits", + "geo-types", + "geoarrow-array", + "geoarrow-schema", + "geodatafusion", + "lance-core", + "serde", +] + [[package]] name = "lance-index" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arc-swap", "arrow", @@ -5131,6 +5407,9 @@ dependencies = [ "dirs", "fst", "futures", + "geo-types", + "geoarrow-array", + "geoarrow-schema", "half", "itertools 0.14.0", "jieba-rs", @@ -5142,6 +5421,7 @@ dependencies = [ "lance-datafusion", "lance-encoding", "lance-file", + "lance-geo", "lance-index-core", "lance-io", "lance-linalg", @@ -5152,7 +5432,7 @@ dependencies = [ "log", "ndarray", "num-traits", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", @@ -5172,8 +5452,8 @@ dependencies = [ [[package]] name = "lance-index-core" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "arrow-schema", @@ -5195,8 +5475,8 @@ dependencies = [ [[package]] name = "lance-io" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow", "arrow-array", @@ -5215,7 +5495,7 @@ dependencies = [ "log", "metrics", "moka", - "object_store", + "object_store 0.14.1", "object_store_opendal", "opendal", "path_abs", @@ -5236,8 +5516,8 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "arrow-schema", @@ -5251,27 +5531,29 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow", "async-trait", "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", + "serde_json", "snafu 0.9.0", ] [[package]] name = "lance-namespace-impls" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow", "arrow-ipc", "arrow-schema", "async-trait", - "axum", + "axum 0.7.9", "base64 0.22.1", "bytes", "chrono", @@ -5287,7 +5569,7 @@ dependencies = [ "lance-namespace", "lance-table", "log", - "object_store", + "object_store 0.14.1", "quick-xml 0.40.1", "rand 0.9.5", "reqwest 0.12.28", @@ -5304,9 +5586,9 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a030196da1c994b63a96a4f0bf5b0cfa459fe6dadc9e962320246ca328da22a" +checksum = "d8d23e54b1634d5bbb434f8dd33dc3c05f6e58d876a9a27b3b4aef58ddbe11af" dependencies = [ "reqwest 0.12.28", "serde", @@ -5318,8 +5600,8 @@ dependencies = [ [[package]] name = "lance-select" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "arrow-buffer", @@ -5333,8 +5615,8 @@ dependencies = [ [[package]] name = "lance-table" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow", "arrow-array", @@ -5355,7 +5637,7 @@ dependencies = [ "lance-io", "lance-select", "log", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", @@ -5374,8 +5656,8 @@ dependencies = [ [[package]] name = "lance-testing" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "arrow-array", "arrow-schema", @@ -5388,8 +5670,8 @@ dependencies = [ [[package]] name = "lance-tokenizer" -version = "12.0.0-beta.2" -source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "frostem", "icu_segmenter", @@ -5402,7 +5684,7 @@ dependencies = [ [[package]] name = "lancedb" -version = "0.38.0-beta.12" +version = "0.40.0-beta.1" dependencies = [ "ahash", "anyhow", @@ -5411,6 +5693,7 @@ dependencies = [ "arrow-buffer", "arrow-cast", "arrow-data", + "arrow-flight", "arrow-ipc", "arrow-ord", "arrow-schema", @@ -5423,6 +5706,8 @@ dependencies = [ "aws-sdk-kms", "aws-sdk-s3", "aws-smithy-runtime", + "aws-smithy-types", + "base64 0.22.1", "bytes", "candle-core", "candle-nn", @@ -5437,6 +5722,7 @@ dependencies = [ "datafusion-physical-expr", "datafusion-physical-plan", "datafusion-sql", + "fs4", "futures", "half", "hf-hub", @@ -5461,11 +5747,13 @@ dependencies = [ "metrics-util", "moka", "num-traits", - "object_store", + "oauth2", + "object_store 0.14.1", "pin-project", "polars", "polars-arrow", "pprof 0.14.1", + "prost", "rand 0.9.5", "random_word", "regex", @@ -5477,20 +5765,23 @@ dependencies = [ "serde_json", "serde_with", "serial_test", + "sha2 0.10.9", "snafu 0.8.9", "tempfile", "test-log", "tokenizers", "tokio", + "tonic", "url", "urlencoding", "uuid", "walkdir", + "webbrowser", ] [[package]] name = "lancedb-nodejs" -version = "0.38.0-beta.12" +version = "0.40.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -5515,8 +5806,9 @@ dependencies = [ [[package]] name = "lancedb-python" -version = "0.38.0-beta.12" +version = "0.40.0-beta.1" dependencies = [ + "arc-swap", "arrow", "async-trait", "bytes", @@ -5539,6 +5831,7 @@ dependencies = [ "serde_json", "snafu 0.8.9", "tokio", + "uuid", ] [[package]] @@ -5748,9 +6041,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loom" @@ -5774,15 +6067,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "lru" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" -dependencies = [ - "hashbrown 0.17.1", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -5859,6 +6143,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -5872,16 +6162,6 @@ dependencies = [ "thread-tree", ] -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if 1.0.4", - "digest 0.10.7", -] - [[package]] name = "md-5" version = "0.11.0" @@ -5894,10 +6174,11 @@ dependencies = [ [[package]] name = "mea" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6747f54621d156e1b47eb6b25f39a941b9fc347f98f67d25d8881ff99e8ed832" +checksum = "c709842c4ce65cb91e2666ad5319dfc1efc3af0d34f02075eddca9000d9f8afb" dependencies = [ + "hashbrown 0.17.1", "slab", ] @@ -6001,9 +6282,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" dependencies = [ "async-lock", "crossbeam-channel", @@ -6097,14 +6378,15 @@ dependencies = [ [[package]] name = "napi" -version = "3.11.0" +version = "3.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" +checksum = "58c5f4d5375213fdb7be2655e152386e82f026f9a5ba36a75556e11359aafe09" dependencies = [ "bitflags 2.11.1", "chrono", - "ctor 1.0.12", + "ctor", "futures", + "libc", "napi-build", "napi-sys", "nohash-hasher", @@ -6116,18 +6398,18 @@ dependencies = [ [[package]] name = "napi-build" -version = "2.4.0" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5282704fbe8d49b0cf8b08e3f33233416a528658f205c7e5ace63b582de0b11c" +checksum = "860e7c40864f95cfb83cde99f9ebadd88ef3d9bdccd7dd2cee0cc96a2dd4ffa7" [[package]] name = "napi-derive" -version = "3.6.1" +version = "3.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d5c9c02556ea6dc99dffd36c1ce60141411657438501a125b675776d011ce92" +checksum = "350057056a30368aa76c11a0d406b0aa61321710be2c36656ab4f6efe0b785a2" dependencies = [ "convert_case", - "ctor 1.0.12", + "ctor", "napi-derive-backend", "proc-macro2", "quote", @@ -6136,9 +6418,9 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "6.1.1" +version = "6.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" +checksum = "4c1c87a71568f3fe5c736b10878ff55064b250bb4ea4b48c8055713e07b9e463" dependencies = [ "convert_case", "proc-macro2", @@ -6171,6 +6453,12 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nibble_vec" version = "0.1.0" @@ -6191,6 +6479,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -6335,12 +6635,73 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "number_prefix" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http 1.5.0", + "rand 0.8.6", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -6348,6 +6709,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -6385,9 +6765,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" dependencies = [ "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-core", + "futures-util", + "http 1.5.0", + "humantime", + "itertools 0.14.0", + "parking_lot", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", "base64 0.22.1", "bytes", "chrono", + "crc-fast", "form_urlencoded", "futures-channel", "futures-core", @@ -6397,14 +6805,14 @@ dependencies = [ "httparse", "humantime", "hyper 1.9.0", - "itertools 0.14.0", - "md-5 0.10.6", + "itertools 0.15.0", + "md-5", + "nix 0.31.3", "parking_lot", "percent-encoding", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "rand 0.10.1", - "reqwest 0.12.28", - "ring", + "reqwest 0.13.4", "rustls-pki-types", "serde", "serde_json", @@ -6416,20 +6824,21 @@ dependencies = [ "walkdir", "wasm-bindgen-futures", "web-time", + "windows-sys 0.61.2", ] [[package]] name = "object_store_opendal" -version = "0.58.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88f165780495c17aa3ce86846600504198c3fffd99073521552751c2430fa6ac" +checksum = "c479bcc317f0ed98972b7184e0f9cc1bea83a07f60cdade96d7207a48ebfb779" dependencies = [ "async-trait", + "asyncband", "bytes", "chrono", "futures", - "mea", - "object_store", + "object_store 0.14.1", "opendal", "pin-project", "tokio", @@ -6483,11 +6892,11 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opendal" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f20562cc7447fcc915fc5c23df305a412ea80a733c9f2fd9e2d267e2815be6d" +checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4" dependencies = [ - "ctor 1.0.12", + "ctor", "opendal-core", "opendal-http-transport-reqwest", "opendal-layer-concurrent-limit", @@ -6506,19 +6915,19 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec75551ff4cf3e57da98979f6a937aaa9ddb3915bf68cc17d03df733be6646ed" +checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b" dependencies = [ "anyhow", + "asyncband", "base64 0.23.1", "bytes", "futures", "http 1.5.0", "jiff", "log", - "md-5 0.11.0", - "mea", + "md-5", "percent-encoding", "quick-xml 0.41.0", "reqsign-core", @@ -6532,9 +6941,9 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad4d4f19c3ce01126a30611f8e544eaa217104a278c889ac17c9374fe4f9e4ef" +checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb" dependencies = [ "bytes", "futures", @@ -6546,21 +6955,21 @@ dependencies = [ [[package]] name = "opendal-layer-concurrent-limit" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "249ac5b0aa5a7a6c3737342d10456067937f9c9a6f3f02544271f7908ab91081" +checksum = "3734c648617e5ad724d49175dde8323550624f9614f50e9e81b7aafae30b1734" dependencies = [ + "asyncband", "futures", "http 1.5.0", - "mea", "opendal-core", ] [[package]] name = "opendal-layer-logging" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c75411ab00f77851ff086b686c1e9ca8175ac18c15afa2cb75b9036436cb06c" +checksum = "0a8daab5a91eac76f94516e7c71e677195c9221840ce8f5e71815e3fc8207742" dependencies = [ "log", "opendal-core", @@ -6568,9 +6977,9 @@ dependencies = [ [[package]] name = "opendal-layer-retry" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80b7738bd5f233ad8da39af9b9316b9b7a4eaddd91e8e32a1e19b7030688121d" +checksum = "eb8e22cb1f86ef9af771a8947d3ccf380c8f5b76236f628314dd41c9ba73331a" dependencies = [ "backon", "log", @@ -6579,9 +6988,9 @@ dependencies = [ [[package]] name = "opendal-layer-timeout" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a704141924500f3803c05ed871b53305d2a2f11cb5ef20160c3ee688a1857f66" +checksum = "08ac6c77fe0d1b18e0064a02c19b98dbebcab78bf5e7753ad1f3abbc18850ff4" dependencies = [ "opendal-core", "tokio", @@ -6589,9 +6998,9 @@ dependencies = [ [[package]] name = "opendal-service-azblob" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3310fbbb48f111c6f590473c2cd15e1b7f8e384444b0d4e328f0464c864d767" +checksum = "c6cf31b02e7b44191d97c07254eea109b5da9c17210a29e478c8d0dc26dffb1f" dependencies = [ "base64 0.23.1", "bytes", @@ -6610,15 +7019,15 @@ dependencies = [ [[package]] name = "opendal-service-azdls" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e3c406729935fe214ce574d68681a1ff7e0b322548f14094912bdbfe50e5c53" +checksum = "354f4a8d26f0fb7639f5c4ad3a924d7c75625830f69550b8c807c080ea0df8bc" dependencies = [ + "asyncband", "base64 0.23.1", "bytes", "http 1.5.0", "log", - "mea", "opendal-core", "opendal-service-azure-common", "quick-xml 0.41.0", @@ -6631,9 +7040,9 @@ dependencies = [ [[package]] name = "opendal-service-azure-common" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7348c88edf15af435b7be930077746b569fac5e738c1bf6a363b675e7317c9df" +checksum = "8107324c3f3cb9970fe20526c2c341b7c5769acbd4699bab73bd525f5fd63d62" dependencies = [ "http 1.5.0", "opendal-core", @@ -6641,9 +7050,9 @@ dependencies = [ [[package]] name = "opendal-service-cos" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d533d4582105d269c8aebeee5f0e8bcf960f41b8aab6197df7012254d9f39bf0" +checksum = "feb737e6609b4fd42e4773b7b2d8d05031b63cd0df46c7ba68ddd64bae1dd3f3" dependencies = [ "bytes", "http 1.5.0", @@ -6658,9 +7067,9 @@ dependencies = [ [[package]] name = "opendal-service-gcs" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007f3fba63c21e516c956b891e96ff9892d8175662bfb781cdada9d3766a11e6" +checksum = "3d80eca6f9227bdf65d43e5b8d5fe75dca8cfab1da07571442a0420400847bea" dependencies = [ "async-trait", "bytes", @@ -6675,28 +7084,30 @@ dependencies = [ "serde", "serde_json", "tokio", + "uuid", ] [[package]] name = "opendal-service-goosefs" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60871e6386f04d831e6a5bdbc032af4a91aeba49963252d0ef456a2cf36a9b78" +checksum = "2134aca0ef64dbff844cb1e608d28833f2a1b34b884e7d6b307416e966694e9f" dependencies = [ + "asyncband", "bytes", "goosefs-sdk", "log", "opendal-core", "serde", - "tokio", ] [[package]] name = "opendal-service-hf" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b41fd41eb7ed03c5e66cefda61e8e117808ffd2908f2916737cb020a6beb02c7" +checksum = "0a0cba627efaee637746804b74bc2c2b2b207fe214307e1e2ba31b49aad708a3" dependencies = [ + "asyncband", "bytes", "hf-xet", "http 1.5.0", @@ -6709,9 +7120,9 @@ dependencies = [ [[package]] name = "opendal-service-oss" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd528ec2d49c5ca69e674ffed7b3e0686fb9cfcfea0596870de381467fda4f1b" +checksum = "a0ebfa355b1d4edb0e5b2a5b86d8de9f811fae1d7baca6d65246b230b2afc0bd" dependencies = [ "bytes", "http 1.5.0", @@ -6726,16 +7137,16 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.58.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58e80cdf192d7eff05feed747894d64f81905ac4eaf132edf7ea270abdd2d663" +checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d" dependencies = [ "base64 0.23.1", "bytes", "crc-fast", "http 1.5.0", "log", - "md-5 0.11.0", + "md-5", "opendal-core", "quick-xml 0.41.0", "reqsign-aws-v4", @@ -7548,7 +7959,7 @@ dependencies = [ "inferno", "libc", "log", - "nix", + "nix 0.26.4", "once_cell", "smallvec", "spin 0.10.1", @@ -7570,7 +7981,7 @@ dependencies = [ "inferno", "libc", "log", - "nix", + "nix 0.26.4", "once_cell", "smallvec", "spin 0.10.1", @@ -7618,9 +8029,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -7647,9 +8058,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", @@ -7733,6 +8144,7 @@ dependencies = [ "pyo3-build-config", "pyo3-ffi", "pyo3-macros", + "uuid", ] [[package]] @@ -7829,16 +8241,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.40.1" @@ -7882,7 +8284,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.40", + "rustls 0.23.45", "socket2 0.6.3", "thiserror 2.0.18", "tokio", @@ -7903,7 +8305,7 @@ dependencies = [ "rand 0.9.5", "ring", "rustc-hash", - "rustls 0.23.40", + "rustls 0.23.45", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -8296,9 +8698,9 @@ dependencies = [ [[package]] name = "reqsign-aws-core" -version = "3.0.3" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4af084e1f3cbf3e67e0c972765399bce54ecec804cceba46b39a8331f3c1bff" +checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" dependencies = [ "bytes", "form_urlencoded", @@ -8317,9 +8719,9 @@ dependencies = [ [[package]] name = "reqsign-aws-v4" -version = "3.1.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac5b3b7cefa28933792b439186459f77f19f9b6edbeab41b8b187150361a206" +checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" dependencies = [ "bytes", "http 1.5.0", @@ -8353,9 +8755,9 @@ dependencies = [ [[package]] name = "reqsign-core" -version = "3.2.1" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07dd510b1e1b9b241883e483358147fb2ed2d497a7b39b065ba61eb93deceb0" +checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" dependencies = [ "anyhow", "base64 0.23.1", @@ -8366,6 +8768,7 @@ dependencies = [ "http 1.5.0", "jiff", "log", + "mea", "percent-encoding", "rsa", "serde", @@ -8377,9 +8780,9 @@ dependencies = [ [[package]] name = "reqsign-file-read-tokio" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "663d9d55abd0df0830ef0ae43708297cc1371cf4e8ca91f3ac813c309cca8c98" +checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" dependencies = [ "anyhow", "reqsign-core", @@ -8388,10 +8791,11 @@ dependencies = [ [[package]] name = "reqsign-google" -version = "3.0.4" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4080a227f82a09f68540ecd028622065d7ac4c0bcb8727a25bdcfc0526235792" +checksum = "272a5813571885c1455ffe106f0c768577e6ce69313446d4476e8d41dcf6ac5a" dependencies = [ + "bytes", "form_urlencoded", "http 1.5.0", "log", @@ -8444,7 +8848,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls 0.23.45", "rustls-native-certs", "rustls-pki-types", "serde", @@ -8475,6 +8879,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", + "h2 0.4.16", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -8486,7 +8891,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls 0.23.45", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -8601,14 +9006,20 @@ dependencies = [ [[package]] name = "roaring" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" dependencies = [ "bytemuck", "byteorder", ] +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + [[package]] name = "rsa" version = "0.9.10" @@ -8630,6 +9041,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + [[package]] name = "rstest" version = "0.23.0" @@ -8718,16 +9140,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki 0.103.15", "subtle", "zeroize", ] @@ -8765,10 +9187,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.40", + "rustls 0.23.45", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", + "rustls-webpki 0.103.15", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -8793,9 +9215,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -9063,16 +9485,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -9083,14 +9506,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -9168,7 +9591,6 @@ dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", - "sha2-asm", ] [[package]] @@ -9182,15 +9604,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sha2-asm" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" -dependencies = [ - "cc", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -9377,6 +9790,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + [[package]] name = "spin" version = "0.9.9" @@ -10018,7 +10443,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.40", + "rustls 0.23.45", "tokio", ] @@ -10047,6 +10472,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -10084,6 +10533,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", + "axum 0.8.9", "base64 0.22.1", "bytes", "h2 0.4.16", @@ -10095,9 +10545,11 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", + "rustls-native-certs", "socket2 0.6.3", "sync_wrapper", "tokio", + "tokio-rustls 0.26.4", "tokio-stream", "tower", "tower-layer", @@ -10405,7 +10857,7 @@ dependencies = [ "flate2", "log", "once_cell", - "rustls 0.23.40", + "rustls 0.23.45", "rustls-pki-types", "serde", "serde_json", @@ -10424,6 +10876,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -10452,9 +10905,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -10677,6 +11130,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -11156,6 +11625,31 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wkb" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a120b336c7ad17749026d50427c23d838ecb50cd64aaea6254b5030152f890a9" +dependencies = [ + "byteorder", + "geo-traits", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "wkt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb2b923ccc882312e559ffaa832a055ba9d1ac0cc8e86b3e25453247e4b81d7" +dependencies = [ + "geo-traits", + "geo-types", + "log", + "num-traits", + "thiserror 1.0.69", +] + [[package]] name = "writeable" version = "0.6.3" @@ -11173,20 +11667,18 @@ dependencies = [ [[package]] name = "xet-client" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", - "clap", "crc32fast", "futures", "http 1.5.0", "hyper 1.9.0", - "lazy_static", "more-asserts", "rand 0.10.1", "redb", @@ -11200,8 +11692,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-retry", + "tokio_with_wasm", "tracing", - "tracing-subscriber", "url", "urlencoding", "web-time", @@ -11211,24 +11703,21 @@ dependencies = [ [[package]] name = "xet-core-structures" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" dependencies = [ "async-trait", "base64 0.22.1", "blake3", "bytemuck", "bytes", - "clap", "countio", - "csv", "futures", "futures-util", "getrandom 0.4.2", "heapify", "itertools 0.14.0", - "lazy_static", "lz4_flex", "more-asserts", "rand 0.10.1", @@ -11236,7 +11725,6 @@ dependencies = [ "safe-transmute", "serde", "static_assertions", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", @@ -11248,32 +11736,31 @@ dependencies = [ [[package]] name = "xet-data" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" dependencies = [ "anyhow", "async-trait", "bytes", "chrono", - "clap", "gearhash", "http 1.5.0", "itertools 0.14.0", - "lazy_static", "more-asserts", "rand 0.10.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "url", "uuid", - "walkdir", + "web-time", "xet-client", "xet-core-structures", "xet-runtime", @@ -11281,9 +11768,9 @@ dependencies = [ [[package]] name = "xet-runtime" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" dependencies = [ "anyhow", "async-trait", @@ -11291,13 +11778,12 @@ dependencies = [ "chrono", "colored", "const-str", - "ctor 0.6.3", + "ctor", "dirs", "futures", "git-version", "humantime", "konst", - "lazy_static", "libc", "more-asserts", "oneshot", @@ -11311,9 +11797,11 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "tracing-appender", "tracing-subscriber", + "web-time", "whoami", "winapi", ] diff --git a/Cargo.toml b/Cargo.toml index a16f0412c..35b5a7d60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,20 +13,20 @@ categories = ["database-implementations"] rust-version = "1.91.0" [workspace.dependencies] -lance = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-core = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-datagen = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-file = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-io = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-index = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-linalg = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-namespace-impls = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-table = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-testing = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-datafusion = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-encoding = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } -lance-arrow = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" } +lance = { "version" = "=13.0.0-beta.4", default-features = false, "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-core = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-datagen = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-file = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-io = { "version" = "=13.0.0-beta.4", default-features = false, "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-index = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-linalg = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-namespace-impls = { "version" = "=13.0.0-beta.4", default-features = false, "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-table = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-testing = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-datafusion = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-encoding = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } +lance-arrow = { "version" = "=13.0.0-beta.4", "tag" = "v13.0.0-beta.4", "git" = "https://github.com/lance-format/lance.git" } lancedb = { path = "rust/lancedb", default-features = false } ahash = "0.8" # Note that this one does not include pyarrow @@ -39,7 +39,10 @@ arrow-ord = "58.0.0" arrow-schema = "58.0.0" arrow-select = "58.0.0" arrow-cast = "58.0.0" +arrow-flight = { version = "58.0.0", features = ["flight-sql-experimental"] } async-trait = "0" +# Smithy JSON 0.63 requires the pre-1.7 Document representation; allow the MSRV pin. +aws-smithy-types = ">=1.3.6, <1.7" bytes = "1" datafusion = { version = "54.0.0", default-features = false } datafusion-catalog = "54.0.0" @@ -59,19 +62,21 @@ log = "0.4" metrics = "0.24" metrics-util = "0.19" moka = { version = "0.12", features = ["future"] } -object_store = "0.13.2" +object_store = "0.14.1" pin-project = "1.0.7" rand = "0.9" snafu = "0.8" url = "2" num-traits = "0.2" +oauth2 = { version = "5.0", default-features = false } regex = "1.10" semver = "1.0.25" serde = "1" serde_json = "1" tempfile = "3.5.0" tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] } -uuid = { version = "1.7.0", features = ["v4"] } +tonic = { version = "0.14", features = ["tls-native-roots", "tls-ring"] } +uuid = { version = "1.7.0", features = ["v4", "v7"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } [profile.ci] diff --git a/Makefile b/Makefile index b558e6ee3..2e665ee28 100644 --- a/Makefile +++ b/Makefile @@ -5,5 +5,5 @@ licenses: cd python && cargo about generate ../about.hbs -o RUST_THIRD_PARTY_LICENSES.html -c ../about.toml cd python && uv sync --all-extras && uv tool run pip-licenses --python .venv/bin/python --format=markdown --with-urls --output-file=PYTHON_THIRD_PARTY_LICENSES.md cd nodejs && cargo about generate ../about.hbs -o RUST_THIRD_PARTY_LICENSES.html -c ../about.toml - cd nodejs && npx license-checker --markdown --out NODEJS_THIRD_PARTY_LICENSES.md + cd nodejs && pnpm dlx license-checker@25 --markdown --out NODEJS_THIRD_PARTY_LICENSES.md cd java && ./mvnw license:aggregate-add-third-party -q diff --git a/ci/update_lockfiles.sh b/ci/update_lockfiles.sh index 9defa6ddc..ddf5fae79 100755 --- a/ci/update_lockfiles.sh +++ b/ci/update_lockfiles.sh @@ -12,16 +12,12 @@ done # This updates the lockfile without building cargo metadata --quiet > /dev/null -pushd nodejs || exit 1 -npm install --package-lock-only --silent -popd - if git diff --quiet --exit-code; then echo "No lockfile changes to commit; skipping amend." elif $AMEND; then - git add Cargo.lock nodejs/package-lock.json + git add Cargo.lock git commit --amend --no-edit else - git add Cargo.lock nodejs/package-lock.json + git add Cargo.lock git commit -m "Update lockfiles" fi diff --git a/docs/README.md b/docs/README.md index c0171f6cb..bce1ec668 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,22 +47,24 @@ pytest -vv python/tests/docs ### Checking typescript examples -The `@lancedb/lancedb` package must be built before running the tests: +The examples depend on `@lancedb/lancedb` at `file:../dist`, so the package must be +built before running the tests. This uses pnpm; see the +[Typescript contributing guide](../nodejs/CONTRIBUTING.md) for the toolchain setup. ```shell pushd nodejs -npm ci -npm run build +pnpm install +pnpm build popd ``` -Then you can run the examples by going to the `nodejs/examples` directory and -running the tests like a normal npm package: +Then you can run the examples by going to the `nodejs/examples` directory, which is a +separate pnpm package with its own lockfile: ```shell pushd nodejs/examples -npm ci -npm test +pnpm install +pnpm test popd ``` @@ -84,6 +86,7 @@ The new files should be checked into the repository. ```shell pushd nodejs -npm run docs +# `pnpm docs` would invoke pnpm's built-in `docs` command, not the script. +pnpm run docs popd ``` diff --git a/docs/openapi.yml b/docs/openapi.yml index 2f9ae7d99..e619aa038 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -155,7 +155,7 @@ paths: vector: type: FixedSizeList description: | - The targetted vector to search for. Required. + The targeted vector to search for. Required. vector_column: type: string description: | @@ -446,6 +446,15 @@ paths: properties: column: type: string + name: + type: string + description: Optional name for the created index. + replace: + type: boolean + default: true + description: | + Whether to replace an existing index with the same resolved + name. Defaults to true. metric_type: type: string nullable: false diff --git a/docs/package-lock.json b/docs/package-lock.json deleted file mode 100644 index e87f3e0ee..000000000 --- a/docs/package-lock.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "name": "lancedb-docs-test", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "lancedb-docs-test", - "version": "1.0.0", - "license": "Apache 2", - "dependencies": { - "apache-arrow": "file:../node/node_modules/apache-arrow", - "vectordb": "file:../node" - }, - "devDependencies": { - "@types/node": "^20.11.8", - "typescript": "^5.3.3" - } - }, - "../node": { - "name": "vectordb", - "version": "0.21.2-beta.0", - "cpu": [ - "x64", - "arm64" - ], - "license": "Apache-2.0", - "os": [ - "darwin", - "linux", - "win32" - ], - "dependencies": { - "@neon-rs/load": "^0.0.74", - "axios": "^1.4.0" - }, - "devDependencies": { - "@neon-rs/cli": "^0.0.160", - "@types/chai": "^4.3.4", - "@types/chai-as-promised": "^7.1.5", - "@types/mocha": "^10.0.1", - "@types/node": "^18.16.2", - "@types/sinon": "^10.0.15", - "@types/temp": "^0.9.1", - "@types/uuid": "^9.0.3", - "@typescript-eslint/eslint-plugin": "^5.59.1", - "apache-arrow-old": "npm:apache-arrow@13.0.0", - "cargo-cp-artifact": "^0.1", - "chai": "^4.3.7", - "chai-as-promised": "^7.1.1", - "eslint": "^8.39.0", - "eslint-config-standard-with-typescript": "^34.0.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-n": "^15.7.0", - "eslint-plugin-promise": "^6.1.1", - "mocha": "^10.2.0", - "openai": "^4.24.1", - "sinon": "^15.1.0", - "temp": "^0.9.4", - "ts-node": "^10.9.1", - "ts-node-dev": "^2.0.0", - "typedoc": "^0.24.7", - "typedoc-plugin-markdown": "^3.15.3", - "typescript": "^5.1.0", - "uuid": "^9.0.0" - }, - "optionalDependencies": { - "@lancedb/vectordb-darwin-arm64": "0.21.2-beta.0", - "@lancedb/vectordb-darwin-x64": "0.21.2-beta.0", - "@lancedb/vectordb-linux-arm64-gnu": "0.21.2-beta.0", - "@lancedb/vectordb-linux-x64-gnu": "0.21.2-beta.0", - "@lancedb/vectordb-win32-x64-msvc": "0.21.2-beta.0" - }, - "peerDependencies": { - "@apache-arrow/ts": "^14.0.2", - "apache-arrow": "^14.0.2" - } - }, - "../node/node_modules/apache-arrow": { - "version": "14.0.2", - "license": "Apache-2.0", - "dependencies": { - "@types/command-line-args": "5.2.0", - "@types/command-line-usage": "5.0.2", - "@types/node": "20.3.0", - "@types/pad-left": "2.1.1", - "command-line-args": "5.2.1", - "command-line-usage": "7.0.1", - "flatbuffers": "23.5.26", - "json-bignum": "^0.0.3", - "pad-left": "^2.1.0", - "tslib": "^2.5.3" - }, - "bin": { - "arrow2csv": "bin/arrow2csv.js" - } - }, - "node_modules/@types/node": { - "version": "20.11.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.8.tgz", - "integrity": "sha512-i7omyekpPTNdv4Jb/Rgqg0RU8YqLcNsI12quKSDkRXNfx7Wxdm6HhK1awT3xTgEkgxPn3bvnSpiEAc7a7Lpyow==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/apache-arrow": { - "resolved": "../node/node_modules/apache-arrow", - "link": true - }, - "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, - "node_modules/vectordb": { - "resolved": "../node", - "link": true - } - } -} diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index 041e55247..000000000 --- a/docs/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "lancedb-docs-test", - "version": "1.0.0", - "description": "auto-generated tests from doc", - "author": "dev@lancedb.com", - "license": "Apache 2", - "dependencies": { - "apache-arrow": "file:../node/node_modules/apache-arrow", - "vectordb": "file:../node" - }, - "scripts": { - "build": "tsc -b && cd ../node && npm run build-release", - "example": "npm run build && node", - "test": "npm run build && ls dist/*.js | xargs -n 1 node" - }, - "devDependencies": { - "@types/node": "^20.11.8", - "typescript": "^5.3.3" - } -} diff --git a/docs/src/java/java.md b/docs/src/java/java.md index e19880e29..4a798c770 100644 --- a/docs/src/java/java.md +++ b/docs/src/java/java.md @@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`: com.lancedb lancedb-core - 0.38.0-beta.12 + 0.40.0-beta.1 ``` diff --git a/docs/src/js/classes/BlobFile.md b/docs/src/js/classes/BlobFile.md new file mode 100644 index 000000000..84a596d1a --- /dev/null +++ b/docs/src/js/classes/BlobFile.md @@ -0,0 +1,62 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / BlobFile + +# Class: BlobFile + +A lazy handle to blob bytes. Create one with [Table.fetchBlobFiles](Table.md#fetchblobfiles). + +## Methods + +### read() + +```ts +read(): Promise +``` + +Reads from the cursor to the end and advances the cursor. + +A second call returns an empty buffer. [BlobFile.readRange](BlobFile.md#readrange) does +not move the cursor. + +#### Returns + +`Promise`<`Buffer`> + +*** + +### readRange() + +```ts +readRange(start, end): Promise +``` + +Reads the half-open byte range `[start, end)`. + +Fails when `end` is past the blob size. Does not move the cursor. + +#### Parameters + +* **start**: `bigint` + +* **end**: `bigint` + +#### Returns + +`Promise`<`Buffer`> + +*** + +### size() + +```ts +size(): bigint +``` + +Returns the blob size in bytes. + +#### Returns + +`bigint` diff --git a/docs/src/js/classes/Catalog.md b/docs/src/js/classes/Catalog.md new file mode 100644 index 000000000..deac5e3ce --- /dev/null +++ b/docs/src/js/classes/Catalog.md @@ -0,0 +1,107 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / Catalog + +# Class: Catalog + +A remote catalog manages databases through the server's root namespace. + +## Accessors + +### uri + +```ts +get uri(): string +``` + +The root namespace endpoint. + +#### Returns + +`string` + +## Methods + +### connectDatabase() + +```ts +connectDatabase(name): Promise +``` + +Connect to an existing database by its logical name. + +#### Parameters + +* **name**: `string` + +#### Returns + +`Promise`<[`Connection`](Connection.md)> + +*** + +### createDatabase() + +```ts +createDatabase(name, options): Promise +``` + +Create a database, or open an existing database when existOk is true. + +#### Parameters + +* **name**: `string` + +* **options** = `{}` + +* **options.existOk?**: `boolean` + +#### Returns + +`Promise`<[`Connection`](Connection.md)> + +*** + +### dropDatabase() + +```ts +dropDatabase(name, options): Promise +``` + +Drop an empty database. The server rejects nonempty databases. + +#### Parameters + +* **name**: `string` + +* **options** = `{}` + +* **options.ignoreMissing?**: `boolean` + +#### Returns + +`Promise`<`void`> + +*** + +### listDatabases() + +```ts +listDatabases(options): Promise +``` + +List one page of databases; pass pageToken from a response for the next page. + +#### Parameters + +* **options** = `{}` + +* **options.limit?**: `number` + +* **options.pageToken?**: `string` + +#### Returns + +`Promise`<[`ListDatabasesResponse`](../interfaces/ListDatabasesResponse.md)> diff --git a/docs/src/js/classes/Connection.md b/docs/src/js/classes/Connection.md index 1c5abd89f..d46d414d3 100644 --- a/docs/src/js/classes/Connection.md +++ b/docs/src/js/classes/Connection.md @@ -180,13 +180,13 @@ abstract createMaterializedView( Define a materialized view named `name` over the table `source`. -The view is created empty, with the query recorded in its schema -metadata; `view.refresh()` computes the rows. The view is a normal -table: it can be queried, indexed and searched, and it appears in -`tableNames`. The source table must have stable row ids (create it with +The view is populated before creation returns. Set `withNoData` to create +only its definition and empty backing table. The view is a normal table: +it can be queried, indexed and searched, and it appears in `tableNames`. +The source table must have stable row ids (create it with the `newTableEnableStableRowIds` storage option); they keep the view's provenance valid across source compactions and cannot be enabled after -a table exists. Local databases only. +a table exists. #### Parameters @@ -202,6 +202,8 @@ a table exists. Local databases only. * **options.where?**: `string` +* **options.withNoData?**: `boolean` + #### Returns `Promise`<[`MaterializedView`](MaterializedView.md)> @@ -373,6 +375,54 @@ Drop all tables in the database. *** +### dropMaterializedView() + +```ts +abstract dropMaterializedView(name, namespacePath?): Promise +``` + +Drop the materialized view named `name`. + +The view may become unavailable before physical cleanup finishes. Use +[dropMaterializedViewAsync](Connection.md#dropmaterializedviewasync) to retain and wait for the cleanup job. + +Rejects a table that exists but is not a materialized view. + +#### Parameters + +* **name**: `string` + +* **namespacePath?**: `string`[] + +#### Returns + +`Promise`<`void`> + +*** + +### dropMaterializedViewAsync() + +```ts +abstract dropMaterializedViewAsync(name, namespacePath?): Promise +``` + +Start dropping the materialized view named `name` and return its cleanup +job without waiting for completion. + +Rejects a table that exists but is not a materialized view. + +#### Parameters + +* **name**: `string` + +* **namespacePath?**: `string`[] + +#### Returns + +`Promise`<[`Job`](Job.md)> + +*** + ### dropNamespace() ```ts @@ -448,26 +498,6 @@ on the returned job to know when cleanup has finished. *** -### getJob() - -```ts -abstract getJob(jobId): Promise -``` - -Describe a single server-side job by id. - -Resolves to `null` when the server has no such job. - -#### Parameters - -* **jobId**: `string` - -#### Returns - -`Promise`<`null` \| [`JobDescription`](../interfaces/JobDescription.md)> - -*** - ### isOpen() ```ts @@ -482,48 +512,6 @@ Return true if the connection has not been closed *** -### job() - -```ts -abstract job(jobId): Job -``` - -A [Job](Job.md) handle for a server-side job by id. - -The handle is constructed without a server round trip; an unknown id -surfaces when the handle is used. Dropping the handle has no effect on -the job itself. - -#### Parameters - -* **jobId**: `string` - -#### Returns - -[`Job`](Job.md) - -*** - -### jobHistory() - -```ts -abstract jobHistory(jobId?): Promise> -``` - -The lifecycle event history of a server-side job, as an Arrow table. - -Lists history across all jobs when `jobId` is omitted. - -#### Parameters - -* **jobId?**: `string` - -#### Returns - -`Promise`<`Table`<`any`>> - -*** - ### listJobs() ```ts @@ -648,6 +636,30 @@ A page of table names and an *** +### openJob() + +```ts +abstract openJob(jobId): Promise +``` + +Open a server-side job by id, returning a handle with its record already +populated. Rejects when the server has no such job, the way +[Connection.openTable](Connection.md#opentable) does for a missing table. + +The returned [Job](Job.md) answers for its own state, specification, +result, failure and event history, so there is no separate +connection-level call for any of them. + +#### Parameters + +* **jobId**: `string` + +#### Returns + +`Promise`<[`Job`](Job.md)> + +*** + ### openMaterializedView() ```ts diff --git a/docs/src/js/classes/Job.md b/docs/src/js/classes/Job.md index 9f723e9e8..cfbe454ec 100644 --- a/docs/src/js/classes/Job.md +++ b/docs/src/js/classes/Job.md @@ -8,19 +8,46 @@ A handle to an operation that may still be running. -## Constructors +The operation may already be complete when the handle is created. -### new Job() +The detail getters read what the handle last observed. Submitting an +operation returns only a job id, so populating them eagerly would cost an +extra round trip on every call: + +- [Job.refresh](Job.md#refresh) and [Job.status](Job.md#status) fetch the whole record. +- [Job.wait](Job.md#wait) records the terminal state it establishes, but not the + rest of the record. +- Everything is null until one of those runs. + +## Accessors + +### creationMs ```ts -new Job(): Job +get creationMs(): null | number ``` +When the job was created, in milliseconds since the epoch. + #### Returns -[`Job`](Job.md) +`null` \| `number` -## Accessors +*** + +### failure + +```ts +get failure(): null | JobFailureInfo +``` + +Why the job failed, when it failed and the server reports a reason. + +#### Returns + +`null` \| [`JobFailureInfo`](../interfaces/JobFailureInfo.md) + +*** ### id @@ -28,8 +55,69 @@ new Job(): Job get id(): null | string ``` -Identifies the operation on the server that is running it. Operations -that run in this process have no server id. The value is opaque. +Identifies the operation on the server that is running it. + +Operations that run in this process have no server id. The value is +opaque: parsing it or storing it to resume the job later is not supported. + +#### Returns + +`null` \| `string` + +*** + +### jobType + +```ts +get jobType(): null | string +``` + +The job's type, as the server names it. Null for an in-process job, which +has no server-side record. + +#### Returns + +`null` \| `string` + +*** + +### result + +```ts +get result(): any +``` + +The job-type-specific terminal result. Null until the job succeeds, so a +job that never terminates reports its progress through [Job.events](Job.md#events) +instead. + +#### Returns + +`any` + +*** + +### spec + +```ts +get spec(): any +``` + +The job-type-specific specification it was submitted with. + +#### Returns + +`any` + +*** + +### state + +```ts +get state(): null | string +``` + +The last observed lifecycle state, without contacting the backend. #### Returns @@ -51,18 +139,61 @@ Request cancellation. Cancelling a finished operation is a no-op. *** +### events() + +```ts +events(options?): Promise> +``` + +This job's recorded lifecycle events. + +Where the getters above report a terminal result only once the job reaches +one, events are written as the job runs and outlive the workers that +produced them. A distributed job records a `claim`/`claim_complete` pair +per unit of work, each carrying `rows_processed`, so a job that never +finishes still accounts for what it did. + +The server caps results at 1000 rows by default and 10,000 at most, and +truncates without saying so, so pass `limit` for a job that emits an event +per fragment. `filter` is a SQL-like expression over the `state`, +`updated_by`, `emitted_from`, `emitted_by`, and `claim_entity` columns. + +#### Parameters + +* **options?**: [`JobEventsOptions`](../interfaces/JobEventsOptions.md) + +#### Returns + +`Promise`<`Table`<`any`>> + +*** + +### refresh() + +```ts +refresh(): Promise +``` + +Ask the backend for this job's current state, and for a server-side job +its full record, then cache it for the getters above. + +#### Returns + +`Promise`<`void`> + +*** + ### status() ```ts status(): Promise ``` -The operation's current lifecycle state: "running", "finished", -"failed", or "cancelled". +The operation's current lifecycle state: "running", "finished", "failed", +or "cancelled". -A point snapshot; unlike [Job.wait](Job.md#wait) it does not block or reject -on a terminal failure state. States a newer server reports that this -client version does not know pass through as-is. +A point snapshot; unlike [Job.wait](Job.md#wait) it does not block or reject on a +terminal failure state. Also refreshes the getters above. #### Returns @@ -70,6 +201,22 @@ client version does not know pass through as-is. *** +### toString() + +```ts +toString(): string +``` + +Every field the handle currently knows, one per line, with the JSON +payloads indented -- a refresh job's spec and result are the point of +printing it. + +#### Returns + +`string` + +*** + ### wait() ```ts diff --git a/docs/src/js/classes/MaterializedView.md b/docs/src/js/classes/MaterializedView.md index e6ff66142..58eaa3c13 100644 --- a/docs/src/js/classes/MaterializedView.md +++ b/docs/src/js/classes/MaterializedView.md @@ -49,7 +49,7 @@ get name(): string definition(): Promise ``` -The query that defines the view, read from its stored schema. +The query that defines the view. #### Returns diff --git a/docs/src/js/classes/MergeInsertBuilder.md b/docs/src/js/classes/MergeInsertBuilder.md index beb6cdfce..81349a0bc 100644 --- a/docs/src/js/classes/MergeInsertBuilder.md +++ b/docs/src/js/classes/MergeInsertBuilder.md @@ -141,7 +141,7 @@ Currently this causes multiple copies of the row to be created but that behavior is subject to change. An optional condition may be specified. If it is, then only -matched rows that satisfy the condtion will be updated. Any +matched rows that satisfy the condition will be updated. Any rows that do not satisfy the condition will be left as they are. Failing to satisfy the condition does not cause a "matched row" to become a "not matched" row. diff --git a/docs/src/js/classes/OAuthSession.md b/docs/src/js/classes/OAuthSession.md new file mode 100644 index 000000000..e80202bea --- /dev/null +++ b/docs/src/js/classes/OAuthSession.md @@ -0,0 +1,105 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / OAuthSession + +# Class: OAuthSession + +Explicit OAuth session lifecycle for the persistent token cache: eager +`login`, non-secret `status`, and local `logout`. + +A session is built from the same [OAuthConfig](../interfaces/OAuthConfig.md) used to connect +(including its `tokenCache` options). A connection created with the same +configuration shares the cache, so logging in here prepares tokens for +later processes without any database request. + +`login` always runs the configured interactive flow and replaces the cached +session (the most recent login wins). `logout` removes only the local +credential; it does not revoke anything with the provider and does not sign +out of a browser SSO session. + +## Example + +```typescript +const config: OAuthConfig = { + issuerUrl: "https://issuer.example.com", + clientId: "my-app", + scopes: ["openid", "offline_access"], + flow: OAuthFlowType.DeviceCode, + tokenCache: { cacheDir: "/tmp/my-app/oauth-cache" }, +}; +const session = new OAuthSession(config); +const status = await session.login(); +``` + +## Constructors + +### new OAuthSession() + +```ts +new OAuthSession(config): OAuthSession +``` + +Create a session manager for the given OAuth configuration. + +#### Parameters + +* **config**: [`OAuthConfig`](../interfaces/OAuthConfig.md) + +#### Returns + +[`OAuthSession`](OAuthSession.md) + +## Methods + +### login() + +```ts +login(): Promise +``` + +Eagerly run the configured authentication flow and store the session. + +A successful login always replaces any prior cached session for this +identity; if the provider does not issue a refresh token (for example +without `offline_access`), the previous record is removed and the status +reports `refreshable == false`. + +#### Returns + +`Promise`<[`SessionStatus`](../interfaces/SessionStatus.md)> + +*** + +### logout() + +```ts +logout(): Promise +``` + +Remove the matching local cached credential. + +This only deletes the local cache entry. It does not revoke the refresh +token with the provider and does not sign out of a browser SSO session. +Repeated calls succeed; `removed` reports whether a credential existed. + +#### Returns + +`Promise`<[`SessionLogout`](../interfaces/SessionLogout.md)> + +*** + +### status() + +```ts +status(): Promise +``` + +Report whether a matching cached session exists, with safe metadata. + +This never contacts the identity provider and never exposes token values. + +#### Returns + +`Promise`<[`SessionStatus`](../interfaces/SessionStatus.md)> diff --git a/docs/src/js/classes/Table.md b/docs/src/js/classes/Table.md index 159348450..dfa0a9819 100644 --- a/docs/src/js/classes/Table.md +++ b/docs/src/js/classes/Table.md @@ -74,10 +74,10 @@ now: the column is committed with no values, and rows get them from [Table#refreshColumn](Table.md#refreshcolumn). Declaring one therefore costs the same on a large table as on an empty one. -A refresh does not revisit rows it has already filled, so mutating an -input leaves the value computed at fill time; recomputing means dropping -the column and declaring it again. While a declaration reads a column, -that column cannot be renamed, retyped or dropped. +A refresh also recomputes the rows whose inputs changed since they were +computed, so a mutated input is reflected by the next refresh. While a +declaration reads a column, that column cannot be renamed, retyped or +dropped. On LanceDB Cloud and Enterprise the expression is planned by the server, and the refresh runs as a server job -- see @@ -137,6 +137,20 @@ containing the new version number of the table after altering the columns. *** +### blobColumns() + +```ts +abstract blobColumns(): Promise +``` + +Blob v2 columns, including nested dotted paths. + +#### Returns + +`Promise`<`string`[]> + +*** + ### branches() ```ts @@ -499,6 +513,54 @@ Drop an index from the table. *** +### fetchBlobFiles() + +```ts +abstract fetchBlobFiles(column, rowIds): Promise<(null | BlobFile)[]> +``` + +Opens lazy blob handles for `column` at the given row IDs using the +table's current checkout. + +Preserves input order, duplicates, and nulls. Use this for large payloads. +See [Table.fetchBlobs](Table.md#fetchblobs) for row-ID validity across versions. + +#### Parameters + +* **column**: `string` + +* **rowIds**: readonly (`number` \| `bigint`)[] + +#### Returns + +`Promise`<(`null` \| [`BlobFile`](BlobFile.md))[]> + +*** + +### fetchBlobs() + +```ts +abstract fetchBlobs(column, rowIds): Promise<(null | Buffer)[]> +``` + +Bytes for `column` at row IDs from [Query.withRowId](Query.md#withrowid). + +Reads the table's current checkout. IDs from another version can fail after +compaction unless stable row ids are enabled. Results keep input order and +duplicates. Null blobs are `null`. Empty blobs are empty buffers. + +#### Parameters + +* **column**: `string` + +* **rowIds**: readonly (`number` \| `bigint`)[] + +#### Returns + +`Promise`<(`null` \| `Buffer`)[]> + +*** + ### flushLsm() ```ts @@ -676,9 +738,17 @@ List all the versions of the table abstract mergeInsert(on): MergeInsertBuilder ``` +Create a [MergeInsertBuilder](MergeInsertBuilder.md), which combines new data with the +existing table in a single transaction — inserting, updating and deleting +rows depending on how they match. + #### Parameters * **on**: `string` \| `string`[] + The column, or columns, to match source rows against target + rows on. Typically a key or id column. Several columns match on the + composite key: a source row updates a target row only when it agrees on + every one of them. #### Returns @@ -846,10 +916,10 @@ abstract refreshColumn(column): Promise Fill the rows of a computed column that hold no value yet. -Rows appended since the last refresh are filled by the next one; rows -already filled are left as they are, so the call is idempotent and does -not observe a mutated input. Local tables only: a remote refresh runs -as a server job, through [Table#refreshColumnAsync](Table.md#refreshcolumnasync). +Rows appended since the last refresh are filled by the next one, and +rows whose inputs changed since they were computed are recomputed; +everything else is left as it is. Local tables only: a remote refresh +runs as a server job, through [Table#refreshColumnAsync](Table.md#refreshcolumnasync). #### Parameters @@ -1258,7 +1328,7 @@ value is 0") Note: if your condition is something like "some_id_column == 7" and you are updating many rows (with different ids) then you will get better performance with a single [`merge_insert`] call instead of -repeatedly calilng this method. +repeatedly calling this method. ##### Parameters diff --git a/docs/src/js/enumerations/ClientAuthMethod.md b/docs/src/js/enumerations/ClientAuthMethod.md new file mode 100644 index 000000000..47fbe3164 --- /dev/null +++ b/docs/src/js/enumerations/ClientAuthMethod.md @@ -0,0 +1,48 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / ClientAuthMethod + +# Enumeration: ClientAuthMethod + +How the client authenticates to the OAuth token endpoint. + +The method applies to every OAuth request that carries client +authentication: client-credentials, authorization-code exchange, +refresh-token, and device-authorization requests. The Azure managed +identity flow ignores this option. + +## Enumeration Members + +### ClientSecretBasic + +```ts +ClientSecretBasic: "client_secret_basic"; +``` + +HTTP Basic authentication. This is the RFC 6749 recommended method and +the normal default for confidential clients, including default Okta +applications. Requires `clientSecret`. + +*** + +### ClientSecretPost + +```ts +ClientSecretPost: "client_secret_post"; +``` + +Credentials in the request body, for providers configured to require it. +Requires `clientSecret`. + +*** + +### None + +```ts +None: "none"; +``` + +No client authentication, for public clients using PKCE or the device +flow. Cannot be combined with `clientSecret`. diff --git a/docs/src/js/enumerations/OAuthFlowType.md b/docs/src/js/enumerations/OAuthFlowType.md index fe546a140..7daae22fb 100644 --- a/docs/src/js/enumerations/OAuthFlowType.md +++ b/docs/src/js/enumerations/OAuthFlowType.md @@ -10,6 +10,16 @@ OAuth authentication flow types. ## Enumeration Members +### AuthorizationCode + +```ts +AuthorizationCode: "authorization_code"; +``` + +Interactive Authorization Code grant, using PKCE by default. + +*** + ### AzureManagedIdentity ```ts @@ -27,3 +37,13 @@ ClientCredentials: "client_credentials"; ``` Client Credentials grant (service-to-service / M2M). + +*** + +### DeviceCode + +```ts +DeviceCode: "device_code"; +``` + +Device Authorization grant for CLI and headless environments. diff --git a/docs/src/js/functions/blob.md b/docs/src/js/functions/blob.md new file mode 100644 index 000000000..20a734cd4 --- /dev/null +++ b/docs/src/js/functions/blob.md @@ -0,0 +1,55 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / blob + +# Function: blob() + +```ts +function blob(name, options): Field +``` + +Declares a `lance.blob.v2` column. + +Query results are descriptors, not payload bytes. Use [Table.fetchBlobs](../classes/Table.md#fetchblobs) +or [Table.fetchBlobFiles](../classes/Table.md#fetchblobfiles) to read bytes. + +## Parameters + +* **name**: `string` + +* **options**: [`BlobOptions`](../type-aliases/BlobOptions.md) = `{}` + +## Returns + +`Field` + +## Example + +```ts +import { readFile } from "node:fs/promises"; +import { Field, Int64, Schema } from "apache-arrow"; +import { blob, connect } from "@lancedb/lancedb"; + +const db = await connect("./data"); +const video = await readFile("clip.mp4"); +const table = await db.createTable( + "videos", + [{ id: 1n, video }], + { + schema: new Schema([ + new Field("id", new Int64()), + blob("video"), + ]), + }, +); + +const rows = await table.query().select(["id"]).withRowId().toArray(); +const rowIds = rows.map((row) => row._rowid as bigint); +const bytes = await table.fetchBlobs("video", rowIds); + +const [handle] = await table.fetchBlobFiles("video", rowIds); +const size = handle!.size(); +const header = await handle!.readRange(0n, size < 65536n ? size : 65536n); +``` diff --git a/docs/src/js/functions/connectCatalog.md b/docs/src/js/functions/connectCatalog.md new file mode 100644 index 000000000..2bdb3182f --- /dev/null +++ b/docs/src/js/functions/connectCatalog.md @@ -0,0 +1,32 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / connectCatalog + +# Function: connectCatalog() + +```ts +function connectCatalog(endpoint, options): Promise +``` + +Connect to an HTTP(S) catalog endpoint. Catalog requests omit database-selection +headers; opened database connections inherit authentication and client options. + +## Parameters + +* **endpoint**: `string` + +* **options**: [`CatalogOptions`](../interfaces/CatalogOptions.md) = `{}` + +## Returns + +`Promise`<[`Catalog`](../classes/Catalog.md)> + +## Example + +```ts +const catalog = await connectCatalog("https://my-server.example", { apiKey: "secret" }); +const db = await catalog.createDatabase("analytics", { existOk: true }); +const page = await catalog.listDatabases({ limit: 20 }); +``` diff --git a/docs/src/js/functions/isBlobField.md b/docs/src/js/functions/isBlobField.md new file mode 100644 index 000000000..944309f90 --- /dev/null +++ b/docs/src/js/functions/isBlobField.md @@ -0,0 +1,22 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / isBlobField + +# Function: isBlobField() + +```ts +function isBlobField(field): boolean +``` + +Checks for the `lance.blob.v2` extension marker. Does not validate the +field's storage type. + +## Parameters + +* **field**: `Field`<`any`> + +## Returns + +`boolean` diff --git a/docs/src/js/functions/makeJsonField.md b/docs/src/js/functions/makeJsonField.md new file mode 100644 index 000000000..bfa6fd6d7 --- /dev/null +++ b/docs/src/js/functions/makeJsonField.md @@ -0,0 +1,36 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / makeJsonField + +# Function: makeJsonField() + +```ts +function makeJsonField(name, nullable): Field +``` + +Create an Arrow field backed by LanceDB's JSON extension type. + +## Parameters + +* **name**: `string` + The field name. + +* **nullable**: `boolean` = `true` + Whether the field accepts null values. + +## Returns + +`Field` + +## Example + +```ts +import { connect, makeJsonField } from "@lancedb/lancedb"; +import { Schema } from "apache-arrow"; + +const schema = new Schema([makeJsonField("metadata")]); +const db = await connect("/path/to/database"); +await db.createTable("items", [{ metadata: '{"source":"api"}' }], { schema }); +``` diff --git a/docs/src/js/globals.md b/docs/src/js/globals.md index beb9cbeff..92e7940f0 100644 --- a/docs/src/js/globals.md +++ b/docs/src/js/globals.md @@ -11,6 +11,7 @@ ## Enumerations +- [ClientAuthMethod](enumerations/ClientAuthMethod.md) - [FullTextQueryType](enumerations/FullTextQueryType.md) - [OAuthFlowType](enumerations/OAuthFlowType.md) - [Occur](enumerations/Occur.md) @@ -19,10 +20,12 @@ ## Classes - [AutoQuery](classes/AutoQuery.md) +- [BlobFile](classes/BlobFile.md) - [BooleanQuery](classes/BooleanQuery.md) - [BoostQuery](classes/BoostQuery.md) - [BranchContents](classes/BranchContents.md) - [Branches](classes/Branches.md) +- [Catalog](classes/Catalog.md) - [Connection](classes/Connection.md) - [HeaderProvider](classes/HeaderProvider.md) - [Index](classes/Index.md) @@ -34,6 +37,7 @@ - [MultiMatchQuery](classes/MultiMatchQuery.md) - [NativeJsHeaderProvider](classes/NativeJsHeaderProvider.md) - [OAuthHeaderProvider](classes/OAuthHeaderProvider.md) +- [OAuthSession](classes/OAuthSession.md) - [PermutationBuilder](classes/PermutationBuilder.md) - [PhraseQuery](classes/PhraseQuery.md) - [Query](classes/Query.md) @@ -61,6 +65,7 @@ - [BranchIndexSummary](interfaces/BranchIndexSummary.md) - [BranchRowCountSummary](interfaces/BranchRowCountSummary.md) - [BucketStats](interfaces/BucketStats.md) +- [CatalogOptions](interfaces/CatalogOptions.md) - [CherryPickError](interfaces/CherryPickError.md) - [CherryPickPreview](interfaces/CherryPickPreview.md) - [CherryPickResult](interfaces/CherryPickResult.md) @@ -96,9 +101,10 @@ - [IvfFlatOptions](interfaces/IvfFlatOptions.md) - [IvfPqOptions](interfaces/IvfPqOptions.md) - [IvfRqOptions](interfaces/IvfRqOptions.md) -- [JobDescription](interfaces/JobDescription.md) +- [JobEventsOptions](interfaces/JobEventsOptions.md) - [JobFailureInfo](interfaces/JobFailureInfo.md) - [JobInfo](interfaces/JobInfo.md) +- [ListDatabasesResponse](interfaces/ListDatabasesResponse.md) - [ListNamespacesOptions](interfaces/ListNamespacesOptions.md) - [ListNamespacesResponse](interfaces/ListNamespacesResponse.md) - [ListTablesOptions](interfaces/ListTablesOptions.md) @@ -121,6 +127,8 @@ - [RestNamespaceConfig](interfaces/RestNamespaceConfig.md) - [RetryConfig](interfaces/RetryConfig.md) - [ScannableOptions](interfaces/ScannableOptions.md) +- [SessionLogout](interfaces/SessionLogout.md) +- [SessionStatus](interfaces/SessionStatus.md) - [ShuffleOptions](interfaces/ShuffleOptions.md) - [SplitCalculatedOptions](interfaces/SplitCalculatedOptions.md) - [SplitHashOptions](interfaces/SplitHashOptions.md) @@ -130,6 +138,7 @@ - [TableStatistics](interfaces/TableStatistics.md) - [TimeoutConfig](interfaces/TimeoutConfig.md) - [TlsConfig](interfaces/TlsConfig.md) +- [TokenCacheOptions](interfaces/TokenCacheOptions.md) - [TokenResponse](interfaces/TokenResponse.md) - [TokenizeOptions](interfaces/TokenizeOptions.md) - [UpdateFieldMetadataResult](interfaces/UpdateFieldMetadataResult.md) @@ -143,6 +152,7 @@ - [AnalyzePlanDistributedMetrics](type-aliases/AnalyzePlanDistributedMetrics.md) - [BaseTokenizer](type-aliases/BaseTokenizer.md) +- [BlobOptions](type-aliases/BlobOptions.md) - [Data](type-aliases/Data.md) - [DataLike](type-aliases/DataLike.md) - [FieldLike](type-aliases/FieldLike.md) @@ -158,10 +168,14 @@ ## Functions - [RecordBatchIterator](functions/RecordBatchIterator.md) +- [blob](functions/blob.md) - [connect](functions/connect.md) +- [connectCatalog](functions/connectCatalog.md) - [connectNamespace](functions/connectNamespace.md) - [instrumentLanceDbMetrics](functions/instrumentLanceDbMetrics.md) +- [isBlobField](functions/isBlobField.md) - [makeArrowTable](functions/makeArrowTable.md) +- [makeJsonField](functions/makeJsonField.md) - [packBits](functions/packBits.md) - [permutationBuilder](functions/permutationBuilder.md) - [tokenize](functions/tokenize.md) diff --git a/docs/src/js/interfaces/CatalogOptions.md b/docs/src/js/interfaces/CatalogOptions.md new file mode 100644 index 000000000..c66823f6c --- /dev/null +++ b/docs/src/js/interfaces/CatalogOptions.md @@ -0,0 +1,81 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / CatalogOptions + +# Interface: CatalogOptions + +Options shared by a catalog and the database connections it returns. + +## Extends + +- `Omit`<`NativeCatalogOptions`, `"oauthConfig"`> + +## Properties + +### apiKey? + +```ts +optional apiKey: string; +``` + +#### Inherited from + +`Omit.apiKey` + +*** + +### clientConfig? + +```ts +optional clientConfig: ClientConfig; +``` + +#### Inherited from + +`Omit.clientConfig` + +*** + +### headerProvider? + +```ts +optional headerProvider: HeaderProvider | () => Record | Promise>; +``` + +Called for each request to supply authentication headers. + +*** + +### oauthConfig? + +```ts +optional oauthConfig: OAuthConfig; +``` + +*** + +### readConsistencyInterval? + +```ts +optional readConsistencyInterval: number; +``` + +#### Inherited from + +`Omit.readConsistencyInterval` + +*** + +### sqlHostOverride? + +```ts +optional sqlHostOverride: string; +``` + +SQL service endpoint inherited by database connections. + +#### Inherited from + +`Omit.sqlHostOverride` diff --git a/docs/src/js/interfaces/ClientConfig.md b/docs/src/js/interfaces/ClientConfig.md index 94cee5b5e..2b4984b8d 100644 --- a/docs/src/js/interfaces/ClientConfig.md +++ b/docs/src/js/interfaces/ClientConfig.md @@ -22,6 +22,10 @@ optional extraHeaders: Record; optional idDelimiter: string; ``` +The delimiter joining a namespace path and a name into one object +identifier. `"$"` is the only supported value, and leaving this unset is +how to get it; anything else is rejected when the connection is created. + *** ### retryConfig? diff --git a/docs/src/js/interfaces/HnswPqOptions.md b/docs/src/js/interfaces/HnswPqOptions.md index 65e6ea0fb..6d42ed28f 100644 --- a/docs/src/js/interfaces/HnswPqOptions.md +++ b/docs/src/js/interfaces/HnswPqOptions.md @@ -118,7 +118,7 @@ Number of sub-vectors of PQ. This value controls how much the vector is compressed during the quantization step. The more sub vectors there are the less the vector is compressed. The default is the dimension of the vector divided by 16. If the dimension is not evenly divisible -by 16 we use the dimension divded by 8. +by 16 we use the dimension divided by 8. The above two cases are highly preferred. Having 8 or 16 values per subvector allows us to use efficient SIMD instructions. diff --git a/docs/src/js/interfaces/IndexOptions.md b/docs/src/js/interfaces/IndexOptions.md index 82764601d..eb1a10c8f 100644 --- a/docs/src/js/interfaces/IndexOptions.md +++ b/docs/src/js/interfaces/IndexOptions.md @@ -16,7 +16,7 @@ optional config: Index; Advanced index configuration -This option allows you to specify a specfic index to create and also +This option allows you to specify a specific index to create and also allows you to pass in configuration for training the index. See the static methods on Index for details on the various index types. diff --git a/docs/src/js/interfaces/IvfPqOptions.md b/docs/src/js/interfaces/IvfPqOptions.md index 7b47c8e53..7109e448c 100644 --- a/docs/src/js/interfaces/IvfPqOptions.md +++ b/docs/src/js/interfaces/IvfPqOptions.md @@ -112,7 +112,7 @@ Number of sub-vectors of PQ. This value controls how much the vector is compressed during the quantization step. The more sub vectors there are the less the vector is compressed. The default is the dimension of the vector divided by 16. If the dimension is not evenly divisible -by 16 we use the dimension divded by 8. +by 16 we use the dimension divided by 8. The above two cases are highly preferred. Having 8 or 16 values per subvector allows us to use efficient SIMD instructions. diff --git a/docs/src/js/interfaces/JobDescription.md b/docs/src/js/interfaces/JobDescription.md deleted file mode 100644 index 5118bf5d6..000000000 --- a/docs/src/js/interfaces/JobDescription.md +++ /dev/null @@ -1,66 +0,0 @@ -[**@lancedb/lancedb**](../README.md) • **Docs** - -*** - -[@lancedb/lancedb](../globals.md) / JobDescription - -# Interface: JobDescription - -A described job from `Connection.getJob`. - -## Properties - -### creationMs - -```ts -creationMs: number; -``` - -When the job was created, in milliseconds since the epoch. - -*** - -### failure? - -```ts -optional failure: JobFailureInfo; -``` - -Why the job failed, when the job is failed and the server reports a -reason. - -*** - -### jobId - -```ts -jobId: string; -``` - -*** - -### jobType - -```ts -jobType: string; -``` - -*** - -### specJson? - -```ts -optional specJson: string; -``` - -The job-type-specific specification as a JSON string, when present. - -*** - -### state - -```ts -state: string; -``` - -Lifecycle state: "running", "finished", "failed", or "cancelled". diff --git a/docs/src/js/interfaces/JobEventsOptions.md b/docs/src/js/interfaces/JobEventsOptions.md new file mode 100644 index 000000000..24831f4f1 --- /dev/null +++ b/docs/src/js/interfaces/JobEventsOptions.md @@ -0,0 +1,29 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / JobEventsOptions + +# Interface: JobEventsOptions + +Which of a job's events [Job.events](../classes/Job.md#events) returns. + +## Properties + +### filter? + +```ts +optional filter: string; +``` + +SQL-like filter over the event columns. + +*** + +### limit? + +```ts +optional limit: number; +``` + +Maximum event rows to return, up to the server maximum of 10,000. diff --git a/docs/src/js/interfaces/JobInfo.md b/docs/src/js/interfaces/JobInfo.md index 3596fc968..01a7ceefc 100644 --- a/docs/src/js/interfaces/JobInfo.md +++ b/docs/src/js/interfaces/JobInfo.md @@ -26,7 +26,7 @@ When the job was created, in milliseconds since the epoch. jobId: string; ``` -The job id -- what `Connection.getJob` and `Connection.cancelJob` +The job id -- what `Connection.openJob` and `Connection.cancelJob` accept. *** diff --git a/docs/src/js/interfaces/ListDatabasesResponse.md b/docs/src/js/interfaces/ListDatabasesResponse.md new file mode 100644 index 000000000..a1a247746 --- /dev/null +++ b/docs/src/js/interfaces/ListDatabasesResponse.md @@ -0,0 +1,23 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / ListDatabasesResponse + +# Interface: ListDatabasesResponse + +## Properties + +### databases + +```ts +databases: string[]; +``` + +*** + +### pageToken? + +```ts +optional pageToken: string; +``` diff --git a/docs/src/js/interfaces/MaterializedViewDefinition.md b/docs/src/js/interfaces/MaterializedViewDefinition.md index 741bbba31..607563de5 100644 --- a/docs/src/js/interfaces/MaterializedViewDefinition.md +++ b/docs/src/js/interfaces/MaterializedViewDefinition.md @@ -50,6 +50,16 @@ projections: [string, string][]; *** +### sourceNamespace + +```ts +sourceNamespace: string[]; +``` + +Namespace holding the source table; empty is the root namespace. + +*** + ### sourceTable ```ts diff --git a/docs/src/js/interfaces/NativeOAuthConfig.md b/docs/src/js/interfaces/NativeOAuthConfig.md index 6959f17dd..8d3f1438d 100644 --- a/docs/src/js/interfaces/NativeOAuthConfig.md +++ b/docs/src/js/interfaces/NativeOAuthConfig.md @@ -15,6 +15,39 @@ All token acquisition and refresh is handled in the Rust layer. ## Properties +### audience? + +```ts +optional audience: string; +``` + +Optional provider-specific audience for authorization and token requests. + +*** + +### callbackPort? + +```ts +optional callbackPort: number; +``` + +Port for the authorization_code loopback callback server. + +*** + +### clientAuthMethod? + +```ts +optional clientAuthMethod: string; +``` + +How the client authenticates to the token endpoint: "none", +"client_secret_basic", or "client_secret_post". Defaults to +"client_secret_basic" when a client secret is set, and "none" for +public clients. + +*** + ### clientId ```ts @@ -41,7 +74,8 @@ Client secret (required for client_credentials). optional flow: string; ``` -Authentication flow: "client_credentials" or "azure_managed_identity" +Authentication flow: "client_credentials", "authorization_code", +"device_code", or "azure_managed_identity" *** @@ -66,6 +100,16 @@ Client ID for user-assigned managed identity (azure_managed_identity). *** +### redirectUri? + +```ts +optional redirectUri: string; +``` + +Loopback redirect URI for authorization_code. + +*** + ### refreshBufferSecs? ```ts @@ -78,6 +122,16 @@ the TTL, each request refreshes the token. *** +### resource? + +```ts +optional resource: string; +``` + +Optional resource indicator for authorization and token requests. + +*** + ### scopes ```ts @@ -86,3 +140,24 @@ scopes: string[]; OAuth scopes to request. For Azure managed identity, exactly one scope or resource is required. For example: `["api://{app_id}/.default"]` + +*** + +### tokenCache? + +```ts +optional tokenCache: TokenCacheOptions; +``` + +Opt in to the persistent token cache so short-lived processes reuse +one session. Only refresh tokens are persisted. + +*** + +### usePkce? + +```ts +optional usePkce: boolean; +``` + +Whether authorization_code uses S256 PKCE (default: true). diff --git a/docs/src/js/interfaces/OAuthConfig.md b/docs/src/js/interfaces/OAuthConfig.md index f9d5d1c7b..0342d62ea 100644 --- a/docs/src/js/interfaces/OAuthConfig.md +++ b/docs/src/js/interfaces/OAuthConfig.md @@ -26,6 +26,18 @@ const config: OAuthConfig = { }; ``` +Providers requiring an explicit target can set `resource` and/or `audience`: +```typescript +const targeted: OAuthConfig = { + issuerUrl: "https://issuer.example.com", + clientId: "app-id", + clientSecret: "secret", + scopes: ["read"], + resource: "https://api.example.com", + audience: "lancedb-api", +}; +``` + ```typescript const config: OAuthConfig = { issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", @@ -35,8 +47,57 @@ const config: OAuthConfig = { }; ``` +The authorization URL is written to stderr before LanceDB tries to open a +browser, so it can be copied in headless environments. +```typescript +const config: OAuthConfig = { + issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", + clientId: "app-id", + scopes: ["openid", "api://lancedb-api/access"], + flow: OAuthFlowType.AuthorizationCode, +}; +``` + +Device Authorization writes the verification URL and user code to stderr +before polling begins. + ## Properties +### audience? + +```ts +optional audience: string; +``` + +Provider-specific audience, forwarded to authorization and token endpoints, +including refresh requests. Not supported for Azure managed identity. + +*** + +### callbackPort? + +```ts +optional callbackPort: number; +``` + +Port for the AuthorizationCode loopback callback server (default: 8400). + +*** + +### clientAuthMethod? + +```ts +optional clientAuthMethod: ClientAuthMethod; +``` + +How the client authenticates to the token endpoint (default: auto). +With a `clientSecret` the default is `ClientAuthMethod.ClientSecretBasic`, +which matches the RFC 6749 recommendation and the default configuration +of Okta confidential applications; without a secret the client is public +and no client authentication is sent. + +*** + ### clientId ```ts @@ -88,6 +149,16 @@ Client ID for user-assigned managed identity (AzureManagedIdentity). *** +### redirectUri? + +```ts +optional redirectUri: string; +``` + +Loopback redirect URI for AuthorizationCode. + +*** + ### refreshBufferSecs? ```ts @@ -100,6 +171,18 @@ the TTL, each request refreshes the token. *** +### resource? + +```ts +optional resource: string; +``` + +Resource indicator (RFC 8707), forwarded verbatim to authorization and token +endpoints, including refresh requests. Must be an absolute URI without a +fragment. Not supported for Azure managed identity. + +*** + ### scopes ```ts @@ -109,3 +192,26 @@ scopes: string[]; OAuth scopes to request. For Azure managed identity, exactly one scope or resource is required. For example: `["api://{app_id}/.default"]` + +*** + +### tokenCache? + +```ts +optional tokenCache: TokenCacheOptions; +``` + +Opt in to the persistent token cache so short-lived processes reuse one +session. Only refresh tokens are persisted. Only supported by +AuthorizationCode and DeviceCode; Azure managed identity is rejected. +Default: unset (memory only). + +*** + +### usePkce? + +```ts +optional usePkce: boolean; +``` + +Protect AuthorizationCode with S256 PKCE (default: true). diff --git a/docs/src/js/interfaces/OptimizeOptions.md b/docs/src/js/interfaces/OptimizeOptions.md index 700632342..110eb3813 100644 --- a/docs/src/js/interfaces/OptimizeOptions.md +++ b/docs/src/js/interfaces/OptimizeOptions.md @@ -26,7 +26,8 @@ const olderThan = new Date(); olderThan.setDate(olderThan.getDate() - 1)); tbl.optimize({cleanupOlderThan: olderThan}); -// Delete all versions except the current version +// Delete versions committed before this point. Versions created by the +// optimize call itself are newer than the cutoff and will be retained. tbl.optimize({cleanupOlderThan: new Date()}); ``` diff --git a/docs/src/js/interfaces/SessionLogout.md b/docs/src/js/interfaces/SessionLogout.md new file mode 100644 index 000000000..c91fe5108 --- /dev/null +++ b/docs/src/js/interfaces/SessionLogout.md @@ -0,0 +1,20 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / SessionLogout + +# Interface: SessionLogout + +Result of [OAuthSession.logout](../classes/OAuthSession.md#logout). + +## Properties + +### removed + +```ts +removed: boolean; +``` + +Whether a cached credential was removed. `false` means no matching +session was cached; logout is idempotent. diff --git a/docs/src/js/interfaces/SessionStatus.md b/docs/src/js/interfaces/SessionStatus.md new file mode 100644 index 000000000..260233d6a --- /dev/null +++ b/docs/src/js/interfaces/SessionStatus.md @@ -0,0 +1,94 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / SessionStatus + +# Interface: SessionStatus + +Safe, non-secret view of a cached OAuth session, returned by +[OAuthSession.status](../classes/OAuthSession.md#status) and [OAuthSession.login](../classes/OAuthSession.md#login). + +## Properties + +### audience? + +```ts +optional audience: string; +``` + +Provider-specific audience used to obtain the cached session, if configured. + +*** + +### clientId + +```ts +clientId: string; +``` + +Client ID of the cached session. + +*** + +### flow + +```ts +flow: string; +``` + +Flow that produced the cached session. + +*** + +### issuerUrl + +```ts +issuerUrl: string; +``` + +Canonical issuer URL of the cached session. + +*** + +### obtainedAt? + +```ts +optional obtainedAt: number; +``` + +When the cached session was obtained, as Unix seconds. + +*** + +### refreshable + +```ts +refreshable: boolean; +``` + +Whether a cached session exists that can obtain tokens without +interactive authentication. Because access tokens are not persisted, +this is `true` exactly when a refresh token is cached; the next +connection refreshes with it rather than opening a browser or device +prompt. + +*** + +### resource? + +```ts +optional resource: string; +``` + +Resource indicator used to obtain the cached session, if configured. + +*** + +### scopes + +```ts +scopes: string[]; +``` + +Canonical (sorted, de-duplicated) scope set of the cached session. diff --git a/docs/src/js/interfaces/TokenCacheOptions.md b/docs/src/js/interfaces/TokenCacheOptions.md new file mode 100644 index 000000000..d96337468 --- /dev/null +++ b/docs/src/js/interfaces/TokenCacheOptions.md @@ -0,0 +1,41 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / TokenCacheOptions + +# Interface: TokenCacheOptions + +Options for the persistent OAuth token cache. + +The cache is opt-in: it is only used when set as `tokenCache` on +[OAuthConfig](OAuthConfig.md). Only refresh tokens are persisted, in a private +directory with owner-only permissions, so short-lived processes can reuse +an authenticated session instead of re-prompting on every start. + +Multiple identities (issuer, client, scopes, resource, audience, flow, client authentication) +get separate cache entries. Within one identity the most recent login wins. + +## Properties + +### cacheDir? + +```ts +optional cacheDir: string; +``` + +Directory that holds cached credentials. Defaults to +`$XDG_CACHE_HOME/lancedb/oauth`, `$HOME/.cache/lancedb/oauth` on Unix, +or `%LOCALAPPDATA%\\lancedb\\oauth` on Windows. The directory is created +with owner-only permissions (`0700`) when missing. + +*** + +### lockTimeoutSecs? + +```ts +optional lockTimeoutSecs: number; +``` + +How long to wait for the cross-process refresh lock before failing, in +seconds (default: 30). diff --git a/docs/src/js/type-aliases/BlobOptions.md b/docs/src/js/type-aliases/BlobOptions.md new file mode 100644 index 000000000..41dbe92c2 --- /dev/null +++ b/docs/src/js/type-aliases/BlobOptions.md @@ -0,0 +1,48 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / BlobOptions + +# Type Alias: BlobOptions + +```ts +type BlobOptions: object; +``` + +## Type declaration + +### dedicatedSizeThreshold? + +```ts +optional dedicatedSizeThreshold: number; +``` + +Max payload bytes stored in a packed sidecar before a dedicated file. Must +be a positive safe integer. + +### inlineSizeThreshold? + +```ts +optional inlineSizeThreshold: number; +``` + +Max payload bytes kept inline in the data file. Zero is allowed. Must be a +safe integer. + +### nullable? + +```ts +optional nullable: boolean; +``` + +Defaults to true. + +### packFileSizeThreshold? + +```ts +optional packFileSizeThreshold: number; +``` + +Max bytes in one packed sidecar before starting another. Must be a positive +safe integer. diff --git a/docs/src/python/python.md b/docs/src/python/python.md index 5b08d0b1d..3ac7c009a 100644 --- a/docs/src/python/python.md +++ b/docs/src/python/python.md @@ -28,6 +28,70 @@ is also an [asynchronous API client](#connections-asynchronous). ::: lancedb.Session +## Catalogs (Synchronous) + +Remote catalogs manage databases through a server's root namespace. Opened databases +are ordinary connections. Dropping a database requires it to be empty. + +::: lancedb.connect_catalog + +::: lancedb.catalog.Catalog + +::: lancedb.catalog.ListDatabasesResponse + +## Remote SQL + +Submit SQL against a remote LanceDB database through the connection. +The connected database and `default_namespace_path=["public"]` are used for +unqualified tables. Fully qualified references can still query other databases +and namespaces available to the same deployment. `execute_query` returns a +reader as soon as its initial result stream is available. `execute_query_async` +returns a query handle immediately; use it to inspect progress, open a reader, +or cancel the query. The SQL client is initialized by the first query and +retained for the lifetime of the remote connection. Query ids are random, +connection-scoped references rather than encoded SQL or durable resume tokens: + +```python +import lancedb + +db = lancedb.connect( + "db://analytics", + api_key="ldb_...", + host_override="https://api.example.com", + sql_host_override="grpc+tls://sql.example.com:10026", +) +reader = db.execute_query( + """ + SELECT events.id, accounts.name + FROM analytics.public.events AS events + JOIN users.public.accounts AS accounts ON events.user_id = accounts.id + """, + default_namespace_path=["public"], +) +for batch in reader: + print(batch.num_rows) + +query = db.execute_query_async("SELECT * FROM events") +print(query.id) +print(query.describe().status) +for batch in query.reader(): + print(batch.num_rows) + +# The async connection exposes the same lifecycle without blocking: +# async_db = await lancedb.connect_async( +# "db://analytics", +# api_key="ldb_...", +# host_override="https://api.example.com", +# sql_host_override="grpc+tls://sql.example.com:10026", +# ) +# reader = await async_db.execute_query("SELECT * FROM events") +# query = await async_db.execute_query_async("SELECT * FROM events") +# description = await async_db.describe_query(query.id) +# async for batch in await query.reader(): +# print(batch.num_rows) +# await query.cancel() +``` + ## Namespaces (Synchronous) A namespace-backed connection resolves tables through a @@ -74,6 +138,10 @@ listing a storage directory. ::: lancedb.functions.UdfDefinition +::: lancedb.secrets.EnvVarSecret + +::: lancedb.secrets.SecretInfo + ::: lancedb.functions.FunctionRegistrationRequest ::: lancedb.functions.FunctionArtifactRequest @@ -82,6 +150,8 @@ listing a storage directory. ::: lancedb.functions.PythonAdapterSpec +::: lancedb.functions.FunctionImage + ::: lancedb.functions.FunctionVersion ::: lancedb.functions.PythonRuntimeSpec @@ -96,6 +166,8 @@ listing a storage directory. ::: lancedb.functions.OutputMapping +::: lancedb.functions.AssignmentMapping + ::: lancedb.functions.FunctionBinding ::: lancedb.functions.RefreshColumnResult @@ -104,6 +176,18 @@ listing a storage directory. ::: lancedb.job.AsyncJob +::: lancedb.job.JobInfo + +::: lancedb.job.JobDescription + +::: lancedb.job.JobFailureInfo + +::: lancedb.sql.Query + +::: lancedb.sql.AsyncQuery + +::: lancedb.sql.QueryDescription + ## Materialized Views (Synchronous) ::: lancedb.materialized_view.MaterializedView @@ -251,6 +335,12 @@ still work. Queries return descriptors. Call ::: lancedb.exceptions.MissingColumnError +::: lancedb.exceptions.JobNotFoundError + +::: lancedb.exceptions.JobFailedError + +::: lancedb.exceptions.JobCancelledError + ## Integrations ## Pydantic @@ -288,6 +378,10 @@ still work. Queries return descriptors. Call ## Connections (Asynchronous) +::: lancedb.connect_catalog_async + +::: lancedb.catalog.AsyncCatalog + Connections represent a connection to a LanceDb database and can be used to create, list, or open tables. diff --git a/docs/tsconfig.json b/docs/tsconfig.json deleted file mode 100644 index 23a30f8b7..000000000 --- a/docs/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "include": [ - "src/*.ts", - ], - "compilerOptions": { - "target": "es2022", - "module": "nodenext", - "declaration": true, - "outDir": "./dist", - "strict": true, - "allowJs": true, - "resolveJsonModule": true, - }, - "exclude": [ - "./dist/*", - ] -} diff --git a/java/lancedb-core/pom.xml b/java/lancedb-core/pom.xml index 3864ed127..0981f4bb2 100644 --- a/java/lancedb-core/pom.xml +++ b/java/lancedb-core/pom.xml @@ -8,7 +8,7 @@ com.lancedb lancedb-parent - 0.38.0-beta.12 + 0.40.0-beta.1 ../pom.xml diff --git a/java/pom.xml b/java/pom.xml index b3521f9c6..53de36b8e 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ com.lancedb lancedb-parent - 0.38.0-beta.12 + 0.40.0-beta.1 pom ${project.artifactId} LanceDB Java SDK Parent POM @@ -28,7 +28,7 @@ UTF-8 15.0.0 - 12.0.0-beta.2 + 13.0.0-beta.4 false 2.30.0 1.7 diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index c3b69424f..6962c5802 100644 --- a/nodejs/Cargo.toml +++ b/nodejs/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "lancedb-nodejs" edition.workspace = true -version = "0.38.0-beta.12" +version = "0.40.0-beta.1" publish = false license.workspace = true description.workspace = true @@ -37,8 +37,9 @@ lzma-sys = { version = "0.1", features = ["static"] } log.workspace = true # Pin to resolve build failures; update periodically for security patches. -aws-lc-sys = "=0.40.0" -aws-lc-rs = "=1.16.3" +# rustls >= 0.23.45 (RUSTSEC-2026-0285) needs aws-lc-rs >= 1.18. +aws-lc-sys = "=0.45.0" +aws-lc-rs = "=1.18.1" [build-dependencies] napi-build = "2.3.1" diff --git a/nodejs/__test__/arrow.test.ts b/nodejs/__test__/arrow.test.ts index 83b4fae46..6c1d5ecba 100644 --- a/nodejs/__test__/arrow.test.ts +++ b/nodejs/__test__/arrow.test.ts @@ -20,6 +20,7 @@ import { fromTableToBuffer, makeArrowTable, makeEmptyTable, + makeJsonField, } from "../lancedb/arrow"; import { EmbeddingFunction, @@ -28,6 +29,21 @@ import { import { EmbeddingFunctionConfig } from "../lancedb/embedding/registry"; import { sanitizeTable } from "../lancedb/sanitize"; +it("creates a nullable JSON field with the Arrow extension metadata", () => { + const field = makeJsonField("metadata"); + + expect(field.name).toBe("metadata"); + expect(field.type).toEqual(new arrow15.Utf8()); + expect(field.nullable).toBe(true); + expect(field.metadata).toEqual( + new Map([["ARROW:extension:name", "arrow.json"]]), + ); +}); + +it("allows JSON fields to be non-nullable", () => { + expect(makeJsonField("metadata", false).nullable).toBe(false); +}); + // biome-ignore lint/suspicious/noExplicitAny: skip function sampleRecords(): Array> { return [ diff --git a/nodejs/__test__/blob.test.ts b/nodejs/__test__/blob.test.ts new file mode 100644 index 000000000..e8e9357e3 --- /dev/null +++ b/nodejs/__test__/blob.test.ts @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import { Field, Int64, List, Schema, Struct, Utf8 } from "apache-arrow"; +import { makeArrowTable } from "../lancedb/arrow"; +import { BlobFile, blob, coerceBlobValue, isBlobField } from "../lancedb/blob"; + +describe("blob()", () => { + it("marks the field as lance.blob.v2", () => { + const field = blob("image", { nullable: false }); + expect(field.nullable).toBe(false); + expect(isBlobField(field)).toBe(true); + expect(field.metadata.get("ARROW:extension:name")).toBe("lance.blob.v2"); + }); + + it("writes encoding thresholds as field metadata", () => { + const field = blob("video", { + inlineSizeThreshold: 1024, + dedicatedSizeThreshold: 2 * 1024 * 1024, + packFileSizeThreshold: 64 * 1024 * 1024, + }); + expect( + field.metadata.get("lance-encoding:blob-inline-size-threshold"), + ).toBe("1024"); + expect( + field.metadata.get("lance-encoding:blob-dedicated-size-threshold"), + ).toBe(String(2 * 1024 * 1024)); + expect( + field.metadata.get("lance-encoding:blob-pack-file-size-threshold"), + ).toBe(String(64 * 1024 * 1024)); + }); + + it("rejects invalid thresholds", () => { + expect(() => blob("image", { inlineSizeThreshold: -1 })).toThrow( + /inlineSizeThreshold must be non-negative/, + ); + expect(() => blob("image", { dedicatedSizeThreshold: 0 })).toThrow( + /dedicatedSizeThreshold must be positive/, + ); + expect(() => blob("image", { packFileSizeThreshold: 1.5 })).toThrow( + /packFileSizeThreshold must be a safe integer/, + ); + expect(() => + blob("image", { dedicatedSizeThreshold: Number.MAX_SAFE_INTEGER + 1 }), + ).toThrow(/dedicatedSizeThreshold must be a safe integer/); + }); +}); + +describe("coerceBlobValue", () => { + it.each([ + ["Buffer", Buffer.from("x"), { data: Buffer.from("x"), uri: null }], + [ + "Uint8Array", + new Uint8Array([120]), + { data: new Uint8Array([120]), uri: null }, + ], + ["URI string", "s3://bucket/key", { data: null, uri: "s3://bucket/key" }], + [ + "data struct", + { data: Buffer.from("y") }, + { data: Buffer.from("y"), uri: null }, + ], + [ + "uri struct", + { uri: "s3://bucket/key" }, + { data: null, uri: "s3://bucket/key" }, + ], + ["null", null, null], + ])("accepts %s", (_name, input, expected) => { + expect(coerceBlobValue(input)).toEqual(expected); + }); + + it.each([ + ["empty URI", "", /uri cannot be empty/], + ["object without data or uri", { position: 0 }, /data' or 'uri/], + [ + "Int16Array", + new Int16Array([1]), + /Blob data must be Buffer or Uint8Array/, + ], + [ + "both data and uri", + { data: Buffer.from("y"), uri: "s3://bucket/key" }, + /exactly one of 'data' or 'uri'/, + ], + [ + "neither data nor uri", + { data: null, uri: null }, + /exactly one of 'data' or 'uri'/, + ], + ])("rejects %s", (_name, input, message) => { + expect(() => coerceBlobValue(input)).toThrow(message); + }); +}); + +describe("BlobFile", () => { + it("rejects constructing BlobFile without a native handle", () => { + expect(() => new (BlobFile as unknown as { new (): BlobFile })()).toThrow( + /fetchBlobFiles/, + ); + }); +}); + +describe("makeArrowTable blob columns", () => { + it("coerces Buffer input onto a blob field", () => { + const schema = new Schema([ + new Field("id", new Int64(), true), + blob("image"), + ]); + const table = makeArrowTable([{ id: 1n, image: Buffer.from("hello") }], { + schema, + }); + expect(isBlobField(table.schema.fields[1])).toBe(true); + const image = table.getChild("image")!; + expect(image.nullCount).toBe(0); + expect(image.getChild("uri")!.get(0)).toBeNull(); + expect(image.getChild("data")!.nullCount).toBe(0); + expect(Buffer.from(image.getChild("data")!.get(0)!).toString()).toBe( + "hello", + ); + }); + + it("coerces Buffer elements inside a list and keeps null slots", () => { + const schema = new Schema([ + new Field("id", new Int64(), true), + new Field("images", new List(blob("image")), true), + ]); + const table = makeArrowTable( + [ + { id: 1n, images: [Buffer.from("a"), Buffer.from("bb")] }, + { id: 2n, images: null }, + { id: 3n, images: [Buffer.from("c"), null] }, + { id: 4n, images: [] }, + ], + { schema }, + ); + const images = table.getChild("images")!; + expect(images.nullCount).toBe(1); + const rows = images.toArray(); + expect(rows[1]).toBeNull(); + expect(Array.from(rows[3] as Iterable)).toHaveLength(0); + const first = Array.from(rows[0] as Iterable<{ data: Uint8Array | null }>); + expect(Buffer.from(first[0].data!).toString()).toBe("a"); + expect(Buffer.from(first[1].data!).toString()).toBe("bb"); + const third = Array.from( + rows[2] as Iterable<{ data: Uint8Array | null } | null>, + ); + expect(Buffer.from(third[0]!.data!).toString()).toBe("c"); + expect(third[1]).toBeNull(); + }); + + it("coerces Buffer fields inside list structs", () => { + const schema = new Schema([ + new Field("id", new Int64(), true), + new Field( + "items", + new List( + new Field( + "item", + new Struct([new Field("name", new Utf8(), true), blob("image")]), + true, + ), + ), + true, + ), + ]); + const table = makeArrowTable( + [ + { + id: 1n, + items: [{ name: "one", image: Buffer.from("alpha") }], + }, + ], + { schema }, + ); + const items = Array.from( + table.getChild("items")!.toArray()[0] as Iterable<{ + name: string; + image: { data: Uint8Array | null }; + }>, + ); + expect(items[0].name).toBe("one"); + expect(Buffer.from(items[0].image.data!).toString()).toBe("alpha"); + }); +}); diff --git a/nodejs/__test__/catalog.test.ts b/nodejs/__test__/catalog.test.ts new file mode 100644 index 000000000..f9158c94f --- /dev/null +++ b/nodejs/__test__/catalog.test.ts @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import * as http from "http"; +import { Catalog, connectCatalog } from "../lancedb"; + +type RecordedRequest = { + url: string; + headers: http.IncomingHttpHeaders; + body: Record; +}; + +async function withCatalog( + responses: [number, unknown][], + callback: (catalog: Catalog, requests: RecordedRequest[]) => Promise, +) { + const requests: RecordedRequest[] = []; + const server = http.createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const body = Buffer.concat(chunks).toString(); + requests.push({ + url: req.url ?? "", + headers: req.headers, + body: body ? JSON.parse(body) : {}, + }); + const [status, response] = responses.shift() ?? [ + 500, + { error: "Unexpected request" }, + ]; + res.writeHead(status, { "content-type": "application/json" }); + res.end(status === 204 ? undefined : JSON.stringify(response)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Missing server address"); + try { + const catalog = await connectCatalog(`http://127.0.0.1:${address.port}`, { + apiKey: "secret", + sqlHostOverride: "grpc+tls://sql.example.com:10026", + clientConfig: { + extraHeaders: { + "X-LanceDB-Database": "wrong-static", + "X-LanceDB-Database-Prefix": "wrong", + }, + }, + headerProvider: () => ({ + "X-LanceDB-Database": "wrong-dynamic", + "X-LanceDB-Database-Prefix": "wrong", + authorization: "Bearer refreshed", + }), + }); + await callback(catalog, requests); + expect(responses).toHaveLength(0); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("remote catalog", () => { + it("uses root namespace routes and preserves independent database scope", async () => { + await withCatalog( + [ + [204, null], + [200, { tables: [] }], + [200, {}], + [200, { tables: [] }], + [200, { tables: [] }], + // biome-ignore lint/style/useNamingConvention: server wire format + [200, { namespaces: ["team/search"], page_token: "next" }], + [204, null], + ], + async (catalog, requests) => { + const first = await catalog.createDatabase("team/search", { + existOk: true, + }); + expect(await first.tableNames()).toEqual([]); + const second = await catalog.connectDatabase("other"); + expect(await second.tableNames()).toEqual([]); + expect(await first.tableNames()).toEqual([]); + expect( + await catalog.listDatabases({ limit: 1, pageToken: "a/b" }), + ).toEqual({ databases: ["team/search"], pageToken: "next" }); + await catalog.dropDatabase("team/search", { ignoreMissing: true }); + expect(requests[0].url).toBe("/v1/namespace/team%2Fsearch/create"); + expect(requests[0].body).toEqual({ mode: "ExistOk" }); + expect(requests[5].url).toBe( + "/v1/namespace/$/list?limit=1&page_token=a%2Fb", + ); + expect(requests[6].body).toEqual({ + mode: "Skip", + behavior: "Restrict", + }); + for (const [i, request] of requests.entries()) { + expect(request.headers["x-lancedb-database"]).toBe( + i === 1 || i === 4 ? "team/search" : i === 3 ? "other" : undefined, + ); + expect(request.headers["x-lancedb-database-prefix"]).toBeUndefined(); + expect(request.headers.authorization).toBe("Bearer refreshed"); + } + }, + ); + }); + + it("propagates lifecycle errors and sends restricted drops", async () => { + await withCatalog( + [ + [404, {}], + [409, {}], + [400, {}], + [404, {}], + ], + async (catalog, requests) => { + await expect(catalog.connectDatabase("missing")).rejects.toThrow( + "missing", + ); + await expect(catalog.createDatabase("exists")).rejects.toThrow( + "exists", + ); + await expect(catalog.dropDatabase("full")).rejects.toThrow(); + await catalog.dropDatabase("missing", { ignoreMissing: true }); + expect(requests[2].body).toEqual({ + mode: "Fail", + behavior: "Restrict", + }); + }, + ); + }); + + it("validates endpoints and pagination", async () => { + await expect(connectCatalog("/tmp/catalog")).rejects.toThrow(); + const catalog = await connectCatalog("http://127.0.0.1:1"); + for (const limit of [0, -1, 1.5, 2147483648]) { + await expect(catalog.listDatabases({ limit })).rejects.toThrow("limit"); + } + await expect(catalog.connectDatabase("a$b")).rejects.toThrow( + "Invalid database name", + ); + }); +}); diff --git a/nodejs/__test__/fixtures/oauth_browser.cmd b/nodejs/__test__/fixtures/oauth_browser.cmd new file mode 100644 index 000000000..8e97db307 --- /dev/null +++ b/nodejs/__test__/fixtures/oauth_browser.cmd @@ -0,0 +1,3 @@ +@rem SPDX-License-Identifier: Apache-2.0 +@rem SPDX-FileCopyrightText: Copyright The LanceDB Authors +@exit /b 0 diff --git a/nodejs/__test__/materialized_view.test.ts b/nodejs/__test__/materialized_view.test.ts index 2e7b2ec4d..7d0e5ebb3 100644 --- a/nodejs/__test__/materialized_view.test.ts +++ b/nodejs/__test__/materialized_view.test.ts @@ -48,17 +48,35 @@ describe("materialized views", () => { expect(definitionFromMetadata(safe, "v").limit).toBe(42); }); + it("reads the namespaced select kind and refuses unknown kinds", () => { + // "namespaced_select" is the namespaced form of "select": same shape, a + // separate kind so readers that predate it refuse instead of resolving + // the source at the root. + const namespaced = new Map([ + [ + DEFINITION_META_KEY, + '{"kind":"namespaced_select","source_table":"people","source_namespace":["ns"]}', + ], + ]); + const definition = definitionFromMetadata(namespaced, "v"); + expect(definition.sourceTable).toBe("people"); + expect(definition.sourceNamespace).toEqual(["ns"]); + + const unknown = new Map([ + [DEFINITION_META_KEY, '{"kind":"select_v3","source_table":"people"}'], + ]); + expect(() => definitionFromMetadata(unknown, "v")).toThrow( + /cannot refresh/, + ); + }); + it("creates, refreshes and queries a view", async () => { const view = await db.createMaterializedView("adults", "people", { select: ["name", ["shout", "upper(name)"]], where: "age >= 18", }); expect(view.name).toBe("adults"); - expect(await view.table().countRows()).toBe(0); - - const result = await view.refresh(); - expect(result.mode).toBe("rebuild"); - expect(Number(result.rowsWritten)).toBe(2); + expect(await view.table().countRows()).toBe(2); const rows = await view.table().query().toArray(); expect(rows.map((r) => r.shout).sort()).toEqual(["ADA", "GRACE"]); @@ -80,7 +98,9 @@ describe("materialized views", () => { }); it("refreshes incrementally after an append", async () => { - const view = await db.createMaterializedView("copy", "people"); + const view = await db.createMaterializedView("copy", "people", { + withNoData: true, + }); await view.refresh(); const people = await db.openTable("people"); @@ -101,6 +121,21 @@ describe("materialized views", () => { await expect(db.openMaterializedView("people")).rejects.toThrow( "not a materialized view", ); + await expect(db.dropMaterializedView("people")).rejects.toThrow( + "not a materialized view", + ); + + await db.dropMaterializedView("adults"); + expect(await db.listMaterializedViews()).toEqual([]); + }); + + it("returns a job when dropping a view asynchronously", async () => { + await db.createMaterializedView("adults", "people"); + + const job = await db.dropMaterializedViewAsync("adults"); + expect(job.id).toBeNull(); + await job.wait(); + expect(await db.listMaterializedViews()).toEqual([]); }); it("rejects an invalid expression at create time", async () => { @@ -133,6 +168,7 @@ describe("materialized views", () => { }); const view = await db.createMaterializedView("quoted", "odd_names", { select: ["order item"], + withNoData: true, }); const result = await view.refresh(); expect(Number(result.rowsWritten)).toBe(1); diff --git a/nodejs/__test__/oauth.test.ts b/nodejs/__test__/oauth.test.ts new file mode 100644 index 000000000..627da786f --- /dev/null +++ b/nodejs/__test__/oauth.test.ts @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import * as fs from "fs"; +import * as http from "http"; +import { execFileSync } from "node:child_process"; +import * as os from "os"; +import * as path from "path"; +import { OAuthConfig, OAuthFlowType, OAuthSession } from "../lancedb/oauth"; + +function tempCacheDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "lancedb-oauth-cache-")); +} + +function deviceConfig(issuerUrl: string, cacheDir: string): OAuthConfig { + return { + issuerUrl, + clientId: "client-id", + scopes: ["openid"], + flow: OAuthFlowType.DeviceCode, + tokenCache: { cacheDir }, + }; +} + +describe("OAuthSession", () => { + beforeAll(() => { + // Child processes inherit the real environment, just as Rust reads it. + // Fail before login if the browser override only exists in Jest's sandbox. + const browser = execFileSync( + process.execPath, + ["-p", "process.env.LANCEDB_OAUTH_BROWSER ?? ''"], + { encoding: "utf8" }, + ).trim(); + expect(browser).not.toBe(""); + expect(browser).toBe(process.env.LANCEDB_OAUTH_BROWSER); + }); + + it("reports an absent session and logout is idempotent", async () => { + const cacheDir = tempCacheDir(); + const session = new OAuthSession( + deviceConfig("https://issuer.example.com", cacheDir), + ); + + const status = await session.status(); + expect(status.refreshable).toBe(false); + expect(status.issuerUrl).toBe("https://issuer.example.com"); + expect(status.clientId).toBe("client-id"); + expect(status.scopes).toEqual(["openid"]); + expect(status.flow).toBe("device_code"); + expect(status.obtainedAt).toBeUndefined(); + + const logout = await session.logout(); + expect(logout.removed).toBe(false); + }); + + it("requires token cache options", () => { + const config: OAuthConfig = { + issuerUrl: "https://issuer.example.com", + clientId: "client-id", + scopes: ["openid"], + flow: OAuthFlowType.DeviceCode, + }; + expect(() => new OAuthSession(config)).toThrow(/token/); + }); + + it("rejects azure managed identity persistence", () => { + const config: OAuthConfig = { + issuerUrl: "https://login.microsoftonline.com/tenant/v2.0", + clientId: "app-id", + scopes: ["api://app/.default"], + flow: OAuthFlowType.AzureManagedIdentity, + tokenCache: { cacheDir: tempCacheDir() }, + }; + expect(() => new OAuthSession(config)).toThrow(/AzureManagedIdentity/); + }); + + it.each([ + {}, + { + resource: "https://api.example.com/a?x=1&y=two", + audience: "audience + & / ü", + }, + ])( + "logs in via device flow with target %j, caches, and logs out", + async (target) => { + const server = new MockIdp(); + await server.start(); + try { + const cacheDir = tempCacheDir(); + const issuerUrl = server.issuerUrl(); + + const config = { ...deviceConfig(issuerUrl, cacheDir), ...target }; + const session = new OAuthSession(config); + const status = await session.login(); + expect(status.refreshable).toBe(true); + expect(status.resource).toBe(config.resource); + expect(status.audience).toBe(config.audience); + expect(status.obtainedAt).toBeGreaterThan(0); + expect(server.state.deviceAuthorizations).toBe(1); + + // An independent session (a fresh "process") sees the cached login. + const other = new OAuthSession(config); + const cached = await other.status(); + expect(cached.refreshable).toBe(true); + + const logout = await other.logout(); + expect(logout.removed).toBe(true); + const again = await session.logout(); + expect(again.removed).toBe(false); + expect((await session.status()).refreshable).toBe(false); + + // Only the initial login used the interactive device flow. + expect(server.state.deviceAuthorizations).toBe(1); + expect(server.state.refreshGrants).toBe(0); + expect(server.requests).toHaveLength(2); + for (const params of server.requests) { + expect(params.getAll("resource")).toEqual( + config.resource === undefined ? [] : [config.resource], + ); + expect(params.getAll("audience")).toEqual( + config.audience === undefined ? [] : [config.audience], + ); + } + } finally { + server.close(); + } + }, + 15000, + ); +}); + +/** Mock IdP with discovery, device authorization, and rotating refresh. */ +class MockIdp { + readonly requests: URLSearchParams[] = []; + readonly state = { + deviceAuthorizations: 0, + refreshGrants: 0, + accessTokensIssued: 0, + currentRefresh: null as string | null, + }; + private server?: http.Server; + private port = 0; + + issuerUrl(): string { + return `http://127.0.0.1:${this.port}`; + } + + async start(): Promise { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString(); + const params = new URLSearchParams(body); + this.handle(req.url ?? "", params, res); + }); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (address && typeof address === "object") { + this.port = address.port; + } + this.server = server; + } + + private handle( + url: string, + params: URLSearchParams, + res: http.ServerResponse, + ): void { + const respond = (status: number, payload: unknown): void => { + const body = JSON.stringify(payload); + res.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); + }; + + if (url === "/.well-known/openid-configuration") { + respond(200, { + // biome-ignore lint/style/useNamingConvention: OAuth wire format + token_endpoint: `${this.issuerUrl()}/token`, + // biome-ignore lint/style/useNamingConvention: OAuth wire format + device_authorization_endpoint: `${this.issuerUrl()}/device`, + }); + return; + } + + if (url === "/device" || url === "/token") { + this.requests.push(params); + } + + if (url === "/device") { + this.state.deviceAuthorizations += 1; + respond(200, { + // biome-ignore lint/style/useNamingConvention: OAuth wire format + device_code: "device-code", + // biome-ignore lint/style/useNamingConvention: OAuth wire format + user_code: "ABCD-EFGH", + // biome-ignore lint/style/useNamingConvention: OAuth wire format + verification_uri: `${this.issuerUrl()}/verify`, + // biome-ignore lint/style/useNamingConvention: OAuth wire format + expires_in: 60, + interval: 1, + }); + return; + } + + if (url === "/token") { + const grantType = params.get("grant_type") ?? ""; + if (grantType === "refresh_token") { + this.state.refreshGrants += 1; + if (params.get("refresh_token") !== this.state.currentRefresh) { + respond(400, { error: "invalid_grant" }); + return; + } + } else if (!grantType.includes("device_code")) { + respond(400, { error: "unsupported_grant_type" }); + return; + } + this.state.accessTokensIssued += 1; + const number = this.state.accessTokensIssued; + const refresh = `refresh-${number}`; + this.state.currentRefresh = refresh; + respond(200, { + // biome-ignore lint/style/useNamingConvention: OAuth wire format + access_token: `access-${number}`, + // biome-ignore lint/style/useNamingConvention: OAuth wire format + refresh_token: refresh, + // biome-ignore lint/style/useNamingConvention: OAuth wire format + expires_in: 3600, + }); + return; + } + + respond(404, {}); + } + + close(): void { + this.server?.close(); + } +} diff --git a/nodejs/__test__/package.test.ts b/nodejs/__test__/package.test.ts index 7743d73d6..90e750321 100644 --- a/nodejs/__test__/package.test.ts +++ b/nodejs/__test__/package.test.ts @@ -5,8 +5,8 @@ import packageJson = require("../package.json"); describe("package metadata", () => { it("requires Node.js type declarations compatible with the runtime", () => { - expect(packageJson.engines.node).toBe(">= 18"); - expect(packageJson.peerDependencies["@types/node"]).toBe(">=18"); + expect(packageJson.engines.node).toBe(">= 22"); + expect(packageJson.peerDependencies["@types/node"]).toBe(">=22"); expect(packageJson.peerDependenciesMeta["@types/node"]).toEqual({ optional: true, }); diff --git a/nodejs/__test__/remote.test.ts b/nodejs/__test__/remote.test.ts index c51cbbbb7..85d725825 100644 --- a/nodejs/__test__/remote.test.ts +++ b/nodejs/__test__/remote.test.ts @@ -3,10 +3,14 @@ import * as http from "http"; import { RequestListener } from "http"; +import packageJson = require("../package.json"); import { + ClientAuthMethod, ClientConfig, Connection, ConnectionOptions, + OAuthConfig, + OAuthFlowType, TlsConfig, connect, } from "../lancedb"; @@ -70,26 +74,28 @@ async function withMockDatabase( try { await callback(db); } finally { - server.close(); + // `close()` alone leaves the port bound until keep-alive sockets drain, so + // a single failing test would cascade into EADDRINUSE for every test after + // it. Destroy the connections and wait for the port to actually be free. + await new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }); } } describe("remote connection", () => { - it("refuses materialized views before issuing any request", async () => { - const paths: string[] = []; + it("lists materialized views through the namespace route", async () => { await withMockDatabase( (req, res) => { - paths.push(req.url ?? ""); - res.writeHead(404).end(); + expect(req.method).toBe("GET"); + expect(req.url).toBe("/v1/namespace/$/materialized_view/list"); + res + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify({ views: ["daily_sales"] })); }, async (db) => { - await expect(db.openMaterializedView("secret_table")).rejects.toThrow( - /only on local databases/, - ); - await expect(db.listMaterializedViews()).rejects.toThrow( - /only on local databases/, - ); - expect(paths).toEqual([]); + expect(await db.listMaterializedViews()).toEqual(["daily_sales"]); }, ); }); @@ -131,7 +137,7 @@ describe("remote connection", () => { (req, res) => { expect(req.headers["x-api-key"]).toEqual("fake"); expect(req.headers["user-agent"]).toEqual( - `LanceDB-Node-Client/${process.env.npm_package_version}`, + `LanceDB-Node-Client/${packageJson.version}`, ); const body = JSON.stringify({ tables: [] }); @@ -435,6 +441,40 @@ describe("remote connection", () => { ]); }); + describe("OAuthConfig", () => { + it("should expose client auth method values", () => { + expect(ClientAuthMethod.None).toBe("none"); + expect(ClientAuthMethod.ClientSecretBasic).toBe("client_secret_basic"); + expect(ClientAuthMethod.ClientSecretPost).toBe("client_secret_post"); + }); + + it("should accept a confidential client with basic auth", () => { + const config: OAuthConfig = { + issuerUrl: "https://issuer.example.com", + clientId: "client-id", + clientSecret: "secret", + scopes: ["openid"], + flow: OAuthFlowType.AuthorizationCode, + clientAuthMethod: ClientAuthMethod.ClientSecretBasic, + }; + + expect(config.clientAuthMethod).toBe(ClientAuthMethod.ClientSecretBasic); + }); + + it("should accept a public PKCE client without auth method or secret", () => { + const config: OAuthConfig = { + issuerUrl: "https://issuer.example.com", + clientId: "client-id", + scopes: ["openid"], + flow: OAuthFlowType.AuthorizationCode, + usePkce: true, + }; + + expect(config.clientSecret).toBeUndefined(); + expect(config.clientAuthMethod).toBeUndefined(); + }); + }); + describe("TlsConfig", () => { it("should create TlsConfig with all fields", () => { const tlsConfig: TlsConfig = { @@ -932,6 +972,7 @@ describe("remote connection jobs surface", () => { const { tableFromArrays, tableToIPC } = await import("apache-arrow"); const eventsTable = tableFromArrays({ state: ["created", "succeeded"] }); const eventsBody = Buffer.from(tableToIPC(eventsTable, "stream")); + const queryEventsPayloads: Record[] = []; await withMockDatabase( (req, res) => { @@ -960,6 +1001,16 @@ describe("remote connection jobs surface", () => { ); } } else if (req.url === "/v1/jobs/describe") { + if (payload["job_id"] === "job-2") { + res + .writeHead(200, { "Content-Type": "application/json" }) + .end( + '{"job_id": "job-2", "job_type": "refresh_column", ' + + '"job_state": "DONE", "creation_ms": 2000, ' + + '"result": {"rows_assigned": 1000000}}', + ); + return; + } if (payload["job_id"] !== "job-1") { res.writeHead(404).end("no such job"); return; @@ -981,6 +1032,7 @@ describe("remote connection jobs surface", () => { .writeHead(200, { "Content-Type": "application/json" }) .end('{"job_id": "job-1"}'); } else if (req.url === "/v1/jobs/query_events") { + queryEventsPayloads.push(payload); res .writeHead(200, { "Content-Type": "application/vnd.apache.arrow.stream", @@ -997,22 +1049,65 @@ describe("remote connection jobs surface", () => { expect(jobs[0].state).toEqual("running"); expect(jobs[1].state).toEqual("finished"); - const description = await db.getJob("job-1"); - expect(description?.state).toEqual("failed"); - expect(JSON.parse(description?.specJson ?? "")).toEqual({ - column: "vec", - }); - expect(description?.failure?.message).toEqual("worker died"); - expect(await db.getJob("missing")).toBeNull(); - expect(await db.cancelJob("job-1")).toBe(true); expect(await db.cancelJob("missing")).toBe(false); - const history = await db.jobHistory("job-1"); - expect(history.numRows).toEqual(2); + // Opening a job hands back a populated handle; a missing one rejects. + await expect(db.openJob("missing")).rejects.toThrow("not found"); + const finished = await db.openJob("job-2"); + expect(finished.state).toEqual("finished"); + expect(finished.result).toEqual({ + // biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format + rows_assigned: 1000000, + }); - const job = db.job("job-1"); + const job = await db.openJob("job-1"); expect(job.id).toEqual("job-1"); + + // openJob already populated the handle; refresh() re-reads it. + expect(job.state).toEqual("failed"); + await job.refresh(); + expect(job.state).toEqual("failed"); + expect(job.jobType).toEqual("create_index"); + expect(job.creationMs).toEqual(1000); + expect(job.spec).toEqual({ column: "vec" }); + expect(job.result).toBeNull(); + expect(job.failure?.message).toEqual("worker died"); + + // The handle reaches its own events, supplying its job id. + const jobEvents = await job.events({ + limit: 500, + filter: "state = 'claim_complete'", + }); + expect(jobEvents.numRows).toEqual(2); + expect(queryEventsPayloads.pop()).toEqual({ + // biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format + job_id: "job-1", + limit: 500, + filter: "state = 'claim_complete'", + }); + + // Printing lays every known field out on its own line, with the JSON + // payloads indented rather than crammed onto one line. + expect(`${job}`).toEqual( + [ + "Job(", + ' id="job-1",', + ' state="failed",', + ' jobType="create_index",', + " creationMs=1000,", + " spec={", + ' "column": "vec"', + " },", + " failure={", + ' "phase": "execute",', + ' "message": "worker died",', + ' "retryable": true', + " },", + ")", + ].join("\n"), + ); + expect(await job.status()).toEqual("failed"); await expect(job.wait()).rejects.toThrow("worker died"); }, diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 554c7fcd3..852bdec80 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -18,6 +18,7 @@ import { Query, Table, VectorQuery, + blob, connect, tokenize, } from "../lancedb"; @@ -52,6 +53,7 @@ import { Operator, instanceOfFullTextQuery, } from "../lancedb/query"; +import { LocalTable } from "../lancedb/table"; describe.each([arrow15, arrow16, arrow17, arrow18])( "Given a table", @@ -281,7 +283,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( numIndices: 0, numRows: 3, // Full on-disk size of the two data files, footers and metadata included. - totalBytes: 684, + totalBytes: 550, }); // Index files count toward totalBytes too (only deletion files and @@ -289,7 +291,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( await table.createIndex("id", { config: Index.btree() }); const statsWithIndex = await table.stats(); expect(statsWithIndex.numIndices).toBe(1); - expect(statsWithIndex.totalBytes).toBeGreaterThan(684); + expect(statsWithIndex.totalBytes).toBeGreaterThan(550); }); it("should overwrite data if asked", async () => { @@ -737,11 +739,12 @@ it("should query documents with LangChain PDF metadata", async () => { describe("merge insert", () => { let tmpDir: tmp.DirResult; + let conn: Connection; let table: Table; beforeEach(async () => { tmpDir = tmp.dirSync({ unsafeCleanup: true }); - const conn = await connect(tmpDir.name); + conn = await connect(tmpDir.name); table = await conn.createTable("some_table", [ { a: 1, b: "a" }, @@ -779,6 +782,38 @@ describe("merge insert", () => { expect(result.map((row) => ({ ...row }))).toEqual(expected); }); + test("upsert on a composite key", async () => { + const composite = await conn.createTable("composite", [ + { shard: "a", id: 1, val: "x" }, + { shard: "a", id: 2, val: "y" }, + { shard: "b", id: 1, val: "z" }, + ]); + + // ("a", 1) matches an existing row and updates it. ("b", 2) agrees with an + // existing row on each key column separately but on neither pair, so it is + // an insert. + const mergeInsertRes = await composite + .mergeInsert(["shard", "id"]) + .whenMatchedUpdateAll() + .whenNotMatchedInsertAll() + .execute([ + { shard: "a", id: 1, val: "X" }, + { shard: "b", id: 2, val: "W" }, + ]); + expect(mergeInsertRes.numUpdatedRows).toBe(1); + expect(mergeInsertRes.numInsertedRows).toBe(1); + + const result = (await composite.toArrow()) + .toArray() + .sort((a, b) => a.shard.localeCompare(b.shard) || a.id - b.id); + + expect(result.map((row) => ({ ...row }))).toEqual([ + { shard: "a", id: 1, val: "X" }, + { shard: "a", id: 2, val: "y" }, + { shard: "b", id: 1, val: "z" }, + { shard: "b", id: 2, val: "W" }, + ]); + }); test("conditional update", async () => { const newData = [ { a: 2, b: "x" }, @@ -2368,6 +2403,276 @@ describe("when dealing with versioning", () => { }); }); +describe("when dealing with blob columns", () => { + let tmpDir: tmp.DirResult; + beforeEach(() => { + tmpDir = tmp.dirSync({ unsafeCleanup: true }); + }); + afterEach(() => { + tmpDir.removeCallback(); + }); + + it("discovers blob columns", async () => { + const { table } = await openBlobTable(); + expect(await table.blobColumns()).toEqual(["image"]); + }); + + it("preserves order, duplicates, and nulls", async () => { + const { table, rowIds } = await openBlobTable(); + const [alphaId, betaId, nullId] = rowIds; + const bytes = await table.fetchBlobs("image", [ + betaId, + alphaId, + betaId, + nullId, + ]); + expect(bytes.map((b) => (b == null ? null : b.toString()))).toEqual([ + "beta", + "alpha", + "beta", + null, + ]); + const files = await table.fetchBlobFiles("image", [ + betaId, + nullId, + alphaId, + ]); + expect(files.map((f) => f == null)).toEqual([false, true, false]); + }); + + it("reads full blob contents", async () => { + const { table, rowIds, alpha, beta } = await openBlobTable(); + const bytes = await table.fetchBlobs("image", rowIds); + expect(bytes[0]!.equals(alpha)).toBe(true); + expect(bytes[1]!.equals(beta)).toBe(true); + const files = await table.fetchBlobFiles("image", rowIds); + expect(files[0]!.size()).toBe(BigInt(alpha.length)); + expect(Buffer.from(await files[0]!.read()).toString()).toBe("alpha"); + expect(Buffer.from(await files[1]!.read()).toString()).toBe("beta"); + }); + + it("reads a half-open range", async () => { + const { table, rowIds } = await openBlobTable(); + const files = await table.fetchBlobFiles("image", rowIds); + expect(Buffer.from(await files[0]!.readRange(0n, 2n)).toString()).toBe( + "al", + ); + }); + + it("readRange does not move the cursor", async () => { + const { table, rowIds, alpha } = await openBlobTable(); + const [handle] = await table.fetchBlobFiles("image", rowIds); + expect((await handle!.readRange(1n, 3n)).toString()).toBe("lp"); + expect(await handle!.read()).toEqual(alpha); + expect(await handle!.read()).toEqual(Buffer.alloc(0)); + }); + + it("fails when readRange end is past the blob size", async () => { + const { table, rowIds, alpha } = await openBlobTable(); + const files = await table.fetchBlobFiles("image", rowIds); + await expect( + files[0]!.readRange(0n, BigInt(alpha.length + 1)), + ).rejects.toThrow(/exceeds blob size/); + }); + + it("rejects fetchBlobs on a non-blob column", async () => { + const { table, rowIds } = await openBlobTable(); + await expect(table.fetchBlobs("id", rowIds)).rejects.toThrow(/blob/i); + }); + + it("discovers and fetches nested blob columns", async () => { + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), true), + new Field("info", new Struct([blob("image")]), true), + ]); + const payload = Buffer.from("nested"); + const table = await db.createTable( + "nested_blobs", + [{ id: 1n, info: { image: payload } }], + { schema }, + ); + expect(await table.blobColumns()).toEqual(["info.image"]); + const rows = await table.query().withRowId().toArray(); + const bytes = await table.fetchBlobs("info.image", [ + rows[0]._rowid as bigint, + ]); + expect(bytes[0]!.equals(payload)).toBe(true); + }); + + it("creates and adds list blob columns", async () => { + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), true), + new Field("images", new List(blob("image")), true), + ]); + const alpha = Buffer.from("alpha"); + const beta = Buffer.from("beta"); + const gamma = Buffer.from("gamma"); + const table = await db.createTable( + "list_blobs", + [{ id: 1n, images: [alpha, beta] }], + { schema }, + ); + await table.add([ + { id: 2n, images: null }, + { id: 3n, images: [gamma, null] }, + { id: 4n, images: [] }, + ]); + expect(await table.blobColumns()).toEqual(["images.image"]); + const rows = await table.query().toArray(); + const byId = new Map(rows.map((row) => [Number(row.id), row])); + expect(descriptorSizes(byId.get(1)!.images)).toEqual([ + alpha.length, + beta.length, + ]); + expect(byId.get(2)!.images).toBeNull(); + expect(descriptorSizes(byId.get(3)!.images)).toEqual([gamma.length, null]); + expect(Array.from(byId.get(4)!.images as Iterable)).toHaveLength( + 0, + ); + }); + + it("creates and adds list struct blob columns", async () => { + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), true), + new Field( + "items", + new List( + new Field( + "item", + new Struct([new Field("name", new Utf8(), true), blob("image")]), + true, + ), + ), + true, + ), + ]); + const alpha = Buffer.from("nested-alpha"); + const beta = Buffer.from("nested-beta"); + const table = await db.createTable( + "list_struct_blobs", + [{ id: 1n, items: [{ name: "one", image: alpha }] }], + { schema }, + ); + await table.add([ + { + id: 2n, + items: [ + { name: "two", image: beta }, + { name: "three", image: null }, + ], + }, + ]); + const rows = await table.query().toArray(); + const byId = new Map(rows.map((row) => [Number(row.id), row])); + expect( + descriptorSizes( + Array.from(byId.get(1)!.items as Iterable<{ image: unknown }>).map( + (item) => item.image, + ), + ), + ).toEqual([alpha.length]); + expect( + descriptorSizes( + Array.from(byId.get(2)!.items as Iterable<{ image: unknown }>).map( + (item) => item.image, + ), + ), + ).toEqual([beta.length, null]); + }); + + it("rejects blob fields inside a fixed-size list", async () => { + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), true), + new Field("frames", new FixedSizeList(2, blob("frame")), true), + ]); + await expect( + db.createTable( + "fsl_blobs", + [{ id: 1n, frames: [Buffer.from("a"), Buffer.from("b")] }], + { schema }, + ), + ).rejects.toThrow( + "Blob fields inside FixedSizeList are not supported. Use List instead.", + ); + }); + + it("rejects blob fields inside a nested fixed-size list", async () => { + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), true), + new Field( + "clip", + new Struct([ + new Field("frames", new FixedSizeList(2, blob("frame")), true), + ]), + true, + ), + ]); + await expect( + db.createTable( + "nested_fsl_blobs", + [ + { + id: 1n, + clip: { frames: [Buffer.from("a"), Buffer.from("b")] }, + }, + ], + { schema }, + ), + ).rejects.toThrow( + "Blob fields inside FixedSizeList are not supported. Use List instead.", + ); + }); + + it("rejects an Arrow table with blob fields inside a fixed-size list", async () => { + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), true), + new Field("frames", new FixedSizeList(2, blob("frame")), true), + ]); + await expect( + db.createTable("fsl_blobs_ipc", new ArrowTable(schema)), + ).rejects.toThrow( + "Blob fields inside FixedSizeList are not supported. Use List instead.", + ); + }); + + function descriptorSizes(values: unknown): (number | null)[] { + return Array.from( + values as Iterable<{ size?: bigint | number } | null>, + ).map((value) => (value == null ? null : Number(value.size))); + } + + async function openBlobTable() { + const db = await connect(tmpDir.name); + const schema = new Schema([ + new Field("id", new Int64(), true), + blob("image"), + ]); + const alpha = Buffer.from("alpha"); + const beta = Buffer.from("beta"); + const table = await db.createTable( + "blobs", + [ + { id: 1n, image: alpha }, + { id: 2n, image: beta }, + { id: 3n, image: null }, + ], + { schema }, + ); + const rows = await table.query().withRowId().toArray(); + const rowIdById = new Map( + rows.map((r) => [Number(r.id), r._rowid as bigint]), + ); + const rowIds = [1, 2, 3].map((id) => rowIdById.get(id)!); + return { table, rowIds, alpha, beta }; + } +}); + describe("when dealing with tags", () => { let tmpDir: tmp.DirResult; beforeEach(() => { @@ -2461,6 +2766,15 @@ describe("when dealing with tags", () => { }); }); +/** Returns a Date strictly later than every instant observed before the call. */ +async function nextMillisecond(): Promise { + const start = Date.now(); + while (Date.now() <= start) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + return new Date(); +} + describe("when optimizing a dataset", () => { let tmpDir: tmp.DirResult; let table: Table; @@ -2483,9 +2797,14 @@ describe("when optimizing a dataset", () => { }); it("cleanups old versions", async () => { - const stats = await table.optimize({ cleanupOlderThan: new Date() }); + // Lance stores version timestamps with nanosecond precision while a JS + // Date only has millisecond precision. A cutoff captured in the same + // millisecond as the last commit would truncate to *before* that commit + // and leave it in place, so wait for the clock to tick over first. + const cutoff = await nextMillisecond(); + const stats = await table.optimize({ cleanupOlderThan: cutoff }); expect(stats.prune.bytesRemoved).toBeGreaterThan(0); - expect(stats.prune.oldVersionsRemoved).toBe(3); + expect(stats.prune.oldVersionsRemoved).toBe(2); }); it("delete unverified", async () => { @@ -2506,6 +2825,24 @@ describe("when optimizing a dataset", () => { }); }); +it("passes cleanupOlderThan to the native binding as an absolute timestamp", async () => { + const optimize = jest.fn().mockResolvedValue({ + compaction: { + filesAdded: 0, + filesRemoved: 0, + fragmentsAdded: 0, + fragmentsRemoved: 0, + }, + prune: { bytesRemoved: 0, oldVersionsRemoved: 0 }, + }); + const table = new LocalTable({ optimize } as never); + const cutoff = new Date("2020-01-02T03:04:05.678Z"); + + await table.optimize({ cleanupOlderThan: cutoff, deleteUnverified: true }); + + expect(optimize).toHaveBeenCalledWith(cutoff.getTime(), true); +}); + describe.each([arrow15, arrow16, arrow17, arrow18])( "when optimizing a dataset", // biome-ignore lint/suspicious/noExplicitAny: @@ -3219,7 +3556,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( const db = await connect(tmpDir.name); const data = [ { text: "fa", vector: [0.1, 0.2, 0.3] }, - { text: "fo", vector: [0.4, 0.5, 0.6] }, + { text: "fo", vector: [0.4, 0.5, 0.6] }, // spellchecker:disable-line { text: "fob", vector: [0.4, 0.5, 0.6] }, { text: "focus", vector: [0.4, 0.5, 0.6] }, { text: "foo", vector: [0.4, 0.5, 0.6] }, @@ -3244,7 +3581,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( const resultSet = new Set(fuzzyResults.map((r) => r.text)); expect(resultSet.has("foo")).toBe(true); expect(resultSet.has("fob")).toBe(true); - expect(resultSet.has("fo")).toBe(true); + expect(resultSet.has("fo")).toBe(true); // spellchecker:disable-line expect(resultSet.has("food")).toBe(true); const prefixResults = await table diff --git a/nodejs/examples/package.json b/nodejs/examples/package.json index 0dce03ac0..c3f962b79 100644 --- a/nodejs/examples/package.json +++ b/nodejs/examples/package.json @@ -8,7 +8,8 @@ "//1": "--experimental-vm-modules is needed to run jest with sentence-transformers", "//2": "--testEnvironment is needed to run jest with sentence-transformers", "//3": "See: https://github.com/huggingface/transformers.js/issues/57", - "test": "node --experimental-vm-modules node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose", + "//4": "jest is invoked by its JS entry, not node_modules/.bin/jest: under pnpm that path is a shell shim, which `node` cannot execute", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testEnvironment jest-environment-node-single-context --verbose", "lint": "biome check *.ts && biome format *.ts", "lint-ci": "biome ci .", "lint-fix": "biome check --write *.ts && pnpm format", diff --git a/nodejs/jest.config.js b/nodejs/jest.config.js index dc99083a5..c137a4b9e 100644 --- a/nodejs/jest.config.js +++ b/nodejs/jest.config.js @@ -1,3 +1,12 @@ +const path = require("node:path"); + +// Set the real process environment before Jest creates its sandbox and workers. +// Assigning process.env inside a test does not reach Rust's std::env. +process.env.LANCEDB_OAUTH_BROWSER = + process.platform === "win32" + ? path.join(__dirname, "__test__/fixtures/oauth_browser.cmd") + : "/usr/bin/true"; + /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: "ts-jest", diff --git a/nodejs/lancedb/arrow.ts b/nodejs/lancedb/arrow.ts index 1b6b98cc9..df875a808 100644 --- a/nodejs/lancedb/arrow.ts +++ b/nodejs/lancedb/arrow.ts @@ -40,6 +40,7 @@ import { } from "apache-arrow"; import { Buffers } from "apache-arrow/data"; import { typedArrayToArrowType } from "./arrow_type"; +import { coerceBlobValue, isBlobField } from "./blob"; import { type EmbeddingFunction } from "./embedding/embedding_function"; import { EmbeddingFunctionConfig, @@ -71,6 +72,30 @@ export type FieldLike = metadata?: Map; }; +/** + * Create an Arrow field backed by LanceDB's JSON extension type. + * + * @param name - The field name. + * @param nullable - Whether the field accepts null values. + * @example + * ```ts + * import { connect, makeJsonField } from "@lancedb/lancedb"; + * import { Schema } from "apache-arrow"; + * + * const schema = new Schema([makeJsonField("metadata")]); + * const db = await connect("/path/to/database"); + * await db.createTable("items", [{ metadata: '{"source":"api"}' }], { schema }); + * ``` + */ +export function makeJsonField(name: string, nullable = true): Field { + return new Field( + name, + new Utf8(), + nullable, + new Map([["ARROW:extension:name", "arrow.json"]]), + ); +} + export type DataLike = | import("apache-arrow").Data | { @@ -430,12 +455,14 @@ export function makeArrowTable( throw new Error("A schema must be provided if data is empty"); } else { schema = new Schema(schema.fields, schemaMetadata); + validateBlobSchema(schema); return new ArrowTable(schema); } } let inferredSchema = inferSchema(data, schema, opt); inferredSchema = new Schema(inferredSchema.fields, schemaMetadata); + validateBlobSchema(inferredSchema); const finalColumns: Record = {}; for (const field of inferredSchema.fields) { @@ -445,6 +472,35 @@ export function makeArrowTable( return new ArrowTable(inferredSchema, finalColumns); } +function validateBlobSchema(schema: Schema): void { + for (const field of schema.fields) { + validateBlobField(field); + } +} + +function validateBlobField(field: Field): void { + if ( + isFixedSizeList(field.type) && + containsBlobField(field.type.children[0]) + ) { + throw new Error( + "Blob fields inside FixedSizeList are not supported. Use List instead.", + ); + } + for (const child of field.type.children ?? []) { + validateBlobField(child); + } +} + +function containsBlobField(field: Field): boolean { + if (isBlobField(field)) { + return true; + } + return (field.type.children ?? []).some((child: Field) => + containsBlobField(child), + ); +} + function isObject(value: unknown): value is Record { return ( typeof value === "object" && @@ -480,6 +536,32 @@ function transposeData( path: string[] = [], ): Vector { const valuesPath = [...path, field.name]; + if (isBlobField(field) && field.type instanceof Struct) { + const blobRows = data.map((datum) => + coerceBlobValue(valueAtPath(datum, valuesPath)), + ); + const childVectors = field.type.children.map((child) => { + const values = blobRows.map((row) => + row == null ? null : (row[child.name as "data" | "uri"] ?? null), + ); + return makeVector(values, child.type, undefined, child.nullable); + }); + const nullCount = blobRows.filter((row) => row === null).length; + const structData = makeData({ + type: field.type, + length: blobRows.length, + nullCount, + nullBitmap: + nullCount > 0 + ? arrowUtil.packBools(blobRows.map((row) => row !== null)) + : undefined, + children: childVectors.map((v) => v.data[0]), + }); + return arrowMakeVector(structData); + } + if (isList(field.type) && containsBlobField(field.type.children[0])) { + return transposeListData(data, field, valuesPath); + } const values = data.map((datum) => valueAtPath(datum, valuesPath)); if (field.type instanceof Struct) { const childFields = field.type.children; @@ -495,7 +577,7 @@ function transposeData( nullCount > 0 ? arrowUtil.packBools(values.map((value) => value !== null)) : undefined, - children: childVectors as unknown as ArrowData[], + children: childVectors.map((v) => v.data[0]), }); return arrowMakeVector(structData); } else { @@ -503,6 +585,48 @@ function transposeData( } } +function transposeListData( + data: Record[], + field: Field, + valuesPath: string[], +): Vector { + const listType = field.type as List; + const childField = listType.children[0]; + const lists = data.map((datum) => valueAtPath(datum, valuesPath)); + const flattened: Record[] = []; + const validity: boolean[] = []; + const offsets: number[] = [0]; + + for (const list of lists) { + if (list == null) { + validity.push(false); + offsets.push(flattened.length); + continue; + } + if (!Array.isArray(list)) { + throw new Error(`expected an array for list field '${field.name}'`); + } + validity.push(true); + for (const element of list) { + flattened.push({ [childField.name]: element }); + } + offsets.push(flattened.length); + } + + const childVector = transposeData(flattened, childField, []); + const nullCount = validity.filter((valid) => !valid).length; + return arrowMakeVector( + makeData({ + type: listType, + length: lists.length, + nullCount, + nullBitmap: nullCount > 0 ? arrowUtil.packBools(validity) : undefined, + valueOffsets: Int32Array.from(offsets), + child: childVector.data[0], + }), + ); +} + /** * Create an empty Arrow table with the provided schema */ @@ -600,7 +724,7 @@ function makeVector( } if (values.length === 0) { throw Error( - "makeVector requires at least one value or the type must be specfied", + "makeVector requires at least one value or the type must be specified", ); } const sampleValue = values.find((val) => val !== null && val !== undefined); @@ -858,7 +982,7 @@ async function applyEmbeddings( * customized by the `embeddingDataType` property of the embedding function. * * If a schema is provided in `makeTableOptions` then it should include the - * embedding columns. If no schema is provded then embedding columns will + * embedding columns. If no schema is provided then embedding columns will * be placed at the end of the table, after all of the input columns. */ export async function convertToTable( @@ -952,6 +1076,7 @@ export async function fromTableToBuffer( schema = sanitizeSchema(schema); } const tableWithEmbeddings = await applyEmbeddings(table, embeddings, schema); + validateBlobSchema(tableWithEmbeddings.schema); const writer = RecordBatchFileWriter.writeAll(tableWithEmbeddings); return Buffer.from(await writer.toUint8Array()); } diff --git a/nodejs/lancedb/blob.ts b/nodejs/lancedb/blob.ts new file mode 100644 index 000000000..244faac09 --- /dev/null +++ b/nodejs/lancedb/blob.ts @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import { Field, LargeBinary, Struct, Utf8 } from "apache-arrow"; +import { BlobFile as NativeBlobFile } from "./native"; + +const BLOB_V2_EXTENSION_NAME = "lance.blob.v2"; + +const INLINE_SIZE_THRESHOLD_KEY = "lance-encoding:blob-inline-size-threshold"; +const DEDICATED_SIZE_THRESHOLD_KEY = + "lance-encoding:blob-dedicated-size-threshold"; +const PACK_FILE_SIZE_THRESHOLD_KEY = + "lance-encoding:blob-pack-file-size-threshold"; + +export type BlobInput = { + data: Buffer | Uint8Array | null; + uri: string | null; +}; + +export type BlobOptions = { + /** Defaults to true. */ + nullable?: boolean; + /** + * Max payload bytes kept inline in the data file. Zero is allowed. Must be a + * safe integer. + */ + inlineSizeThreshold?: number; + /** + * Max payload bytes stored in a packed sidecar before a dedicated file. Must + * be a positive safe integer. + */ + dedicatedSizeThreshold?: number; + /** + * Max bytes in one packed sidecar before starting another. Must be a positive + * safe integer. + */ + packFileSizeThreshold?: number; +}; + +/** + * Declares a `lance.blob.v2` column. + * + * Query results are descriptors, not payload bytes. Use {@link Table.fetchBlobs} + * or {@link Table.fetchBlobFiles} to read bytes. + * + * @example + * ```ts + * import { readFile } from "node:fs/promises"; + * import { Field, Int64, Schema } from "apache-arrow"; + * import { blob, connect } from "@lancedb/lancedb"; + * + * const db = await connect("./data"); + * const video = await readFile("clip.mp4"); + * const table = await db.createTable( + * "videos", + * [{ id: 1n, video }], + * { + * schema: new Schema([ + * new Field("id", new Int64()), + * blob("video"), + * ]), + * }, + * ); + * + * const rows = await table.query().select(["id"]).withRowId().toArray(); + * const rowIds = rows.map((row) => row._rowid as bigint); + * const bytes = await table.fetchBlobs("video", rowIds); + * + * const [handle] = await table.fetchBlobFiles("video", rowIds); + * const size = handle!.size(); + * const header = await handle!.readRange(0n, size < 65536n ? size : 65536n); + * ``` + */ +export function blob(name: string, options: BlobOptions = {}): Field { + const metadata = new Map([ + ["ARROW:extension:name", BLOB_V2_EXTENSION_NAME], + ]); + setThreshold( + metadata, + INLINE_SIZE_THRESHOLD_KEY, + "inlineSizeThreshold", + options.inlineSizeThreshold, + 0, + ); + setThreshold( + metadata, + DEDICATED_SIZE_THRESHOLD_KEY, + "dedicatedSizeThreshold", + options.dedicatedSizeThreshold, + 1, + ); + setThreshold( + metadata, + PACK_FILE_SIZE_THRESHOLD_KEY, + "packFileSizeThreshold", + options.packFileSizeThreshold, + 1, + ); + return new Field( + name, + new Struct([ + new Field("data", new LargeBinary(), true), + new Field("uri", new Utf8(), true), + ]), + options.nullable ?? true, + metadata, + ); +} + +/** + * Checks for the `lance.blob.v2` extension marker. Does not validate the + * field's storage type. + */ +export function isBlobField(field: Field): boolean { + return field.metadata?.get("ARROW:extension:name") === BLOB_V2_EXTENSION_NAME; +} + +/** + * A lazy handle to blob bytes. Create one with {@link Table.fetchBlobFiles}. + * + * @hideconstructor + */ +export class BlobFile { + private readonly inner: NativeBlobFile; + + private constructor(inner: NativeBlobFile) { + if (!(inner instanceof NativeBlobFile)) { + throw new Error("BlobFile handles come from Table.fetchBlobFiles"); + } + this.inner = inner; + } + + /** @ignore */ + static fromNative(inner: NativeBlobFile): BlobFile { + return new BlobFile(inner); + } + + /** Returns the blob size in bytes. */ + size(): bigint { + return this.inner.size(); + } + + /** + * Reads from the cursor to the end and advances the cursor. + * + * A second call returns an empty buffer. {@link BlobFile.readRange} does + * not move the cursor. + */ + read(): Promise { + return this.inner.read(); + } + + /** + * Reads the half-open byte range `[start, end)`. + * + * Fails when `end` is past the blob size. Does not move the cursor. + */ + readRange(start: bigint, end: bigint): Promise { + return this.inner.readRange(start, end); + } +} + +export function coerceBlobValue(value: unknown): BlobInput | null { + if (value == null) { + return null; + } + if (isBlobBytes(value)) { + return { data: value, uri: null }; + } + if (ArrayBuffer.isView(value)) { + throw new Error("Blob data must be Buffer or Uint8Array"); + } + if (typeof value === "string") { + if (value === "") { + throw new Error("Blob uri cannot be empty"); + } + return { data: null, uri: value }; + } + if (typeof value === "object") { + const record = value as Record; + if (!("data" in record) && !("uri" in record)) { + throw new Error( + "Blob struct values must include a 'data' or 'uri' field", + ); + } + const uri = record.uri; + if (uri === "") { + throw new Error("Blob uri cannot be empty"); + } + if (uri != null && typeof uri !== "string") { + throw new Error(`Blob uri must be a string or null, got ${typeof uri}`); + } + const data = record.data; + if (data != null && !isBlobBytes(data)) { + throw new Error("Blob data must be Buffer, Uint8Array, or null"); + } + const bytes = (data as Buffer | Uint8Array | null | undefined) ?? null; + const uriValue = uri ?? null; + if ((bytes == null) === (uriValue == null)) { + throw new Error( + "Blob struct values must set exactly one of 'data' or 'uri'", + ); + } + return { data: bytes, uri: uriValue }; + } + throw new Error( + "Blob column values must be Buffer, Uint8Array, a URI string, null, or { data?, uri? }", + ); +} + +function isBlobBytes(value: unknown): value is Buffer | Uint8Array { + return Buffer.isBuffer(value) || value instanceof Uint8Array; +} + +function setThreshold( + metadata: Map, + key: string, + optionName: string, + value: number | undefined, + minimum: number, +): void { + if (value === undefined) { + return; + } + if (!Number.isSafeInteger(value)) { + throw new Error(`${optionName} must be a safe integer`); + } + if (value < minimum) { + throw new Error( + minimum <= 0 + ? `${optionName} must be non-negative` + : `${optionName} must be positive`, + ); + } + metadata.set(key, String(value)); +} diff --git a/nodejs/lancedb/catalog.ts b/nodejs/lancedb/catalog.ts new file mode 100644 index 000000000..5cfe2c5ea --- /dev/null +++ b/nodejs/lancedb/catalog.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import { Connection, LocalConnection } from "./connection"; +import { HeaderProvider } from "./header"; +import { + JsHeaderProvider, + ListDatabasesResponse, + Catalog as NativeCatalog, + CatalogOptions as NativeCatalogOptions, +} from "./native.js"; +import { OAuthConfig } from "./oauth"; + +/** Options shared by a catalog and the database connections it returns. */ +export interface CatalogOptions + extends Omit { + oauthConfig?: OAuthConfig; + /** Called for each request to supply authentication headers. */ + headerProvider?: + | HeaderProvider + | (() => Record | Promise>); +} + +export type { ListDatabasesResponse } from "./native.js"; + +/** A remote catalog manages databases through the server's root namespace. */ +export class Catalog { + /** @hidden */ + constructor(private readonly inner: NativeCatalog) {} + + /** The root namespace endpoint. */ + get uri(): string { + return this.inner.uri; + } + + /** Create a database, or open an existing database when existOk is true. */ + async createDatabase( + name: string, + options: { existOk?: boolean } = {}, + ): Promise { + return new LocalConnection( + await this.inner.createDatabase(name, options.existOk), + ); + } + + /** Connect to an existing database by its logical name. */ + async connectDatabase(name: string): Promise { + return new LocalConnection(await this.inner.connectDatabase(name)); + } + + /** Drop an empty database. The server rejects nonempty databases. */ + async dropDatabase( + name: string, + options: { ignoreMissing?: boolean } = {}, + ): Promise { + await this.inner.dropDatabase(name, options.ignoreMissing); + } + + /** List one page of databases; pass pageToken from a response for the next page. */ + async listDatabases( + options: { limit?: number; pageToken?: string } = {}, + ): Promise { + if ( + options.limit !== undefined && + (!Number.isInteger(options.limit) || + options.limit <= 0 || + options.limit > 2147483647) + ) { + throw new Error( + "Database list limit must be an integer between 1 and 2147483647", + ); + } + return this.inner.listDatabases(options.limit, options.pageToken); + } +} + +/** + * Connect to an HTTP(S) catalog endpoint. Catalog requests omit database-selection + * headers; opened database connections inherit authentication and client options. + * + * @example + * ```ts + * const catalog = await connectCatalog("https://my-server.example", { apiKey: "secret" }); + * const db = await catalog.createDatabase("analytics", { existOk: true }); + * const page = await catalog.listDatabases({ limit: 20 }); + * ``` + */ +export async function connectCatalog( + endpoint: string, + options: CatalogOptions = {}, +): Promise { + const { headerProvider, ...nativeOptions } = options; + const provider = headerProvider + ? new JsHeaderProvider(async () => + typeof headerProvider === "function" + ? headerProvider() + : headerProvider.getHeaders(), + ) + : undefined; + return new Catalog( + await NativeCatalog.new(endpoint, nativeOptions, provider), + ); +} diff --git a/nodejs/lancedb/connection.ts b/nodejs/lancedb/connection.ts index 263a338ab..f5a6679cf 100644 --- a/nodejs/lancedb/connection.ts +++ b/nodejs/lancedb/connection.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors -import { tableFromIPC } from "apache-arrow"; import { Data, SchemaLike, @@ -16,6 +15,7 @@ import { makeEmptyTable, } from "./arrow"; import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry"; +import { Job } from "./job"; import { MaterializedView, MaterializedViewSelect, @@ -27,8 +27,6 @@ import type { CreateNamespaceResponse, DescribeNamespaceResponse, DropNamespaceResponse, - Job, - JobDescription, JobInfo, ListNamespacesResponse, ListTablesResponse, @@ -322,13 +320,13 @@ export abstract class Connection { /** * Define a materialized view named `name` over the table `source`. * - * The view is created empty, with the query recorded in its schema - * metadata; `view.refresh()` computes the rows. The view is a normal - * table: it can be queried, indexed and searched, and it appears in - * `tableNames`. The source table must have stable row ids (create it with + * The view is populated before creation returns. Set `withNoData` to create + * only its definition and empty backing table. The view is a normal table: + * it can be queried, indexed and searched, and it appears in `tableNames`. + * The source table must have stable row ids (create it with * the `newTableEnableStableRowIds` storage option); they keep the view's * provenance valid across source compactions and cannot be enabled after - * a table exists. Local databases only. + * a table exists. */ abstract createMaterializedView( name: string, @@ -337,6 +335,7 @@ export abstract class Connection { select?: MaterializedViewSelect; where?: string; limit?: number; + withNoData?: boolean; }, ): Promise; @@ -354,6 +353,30 @@ export abstract class Connection { */ abstract listMaterializedViews(): Promise; + /** + * Drop the materialized view named `name`. + * + * The view may become unavailable before physical cleanup finishes. Use + * {@link dropMaterializedViewAsync} to retain and wait for the cleanup job. + * + * Rejects a table that exists but is not a materialized view. + */ + abstract dropMaterializedView( + name: string, + namespacePath?: string[], + ): Promise; + + /** + * Start dropping the materialized view named `name` and return its cleanup + * job without waiting for completion. + * + * Rejects a table that exists but is not a materialized view. + */ + abstract dropMaterializedViewAsync( + name: string, + namespacePath?: string[], + ): Promise; + abstract openTable( name: string, namespacePath?: string[], @@ -557,24 +580,19 @@ export abstract class Connection { ): Promise; /** - * A {@link Job} handle for a server-side job by id. + * Open a server-side job by id, returning a handle with its record already + * populated. Rejects when the server has no such job, the way + * {@link Connection.openTable} does for a missing table. * - * The handle is constructed without a server round trip; an unknown id - * surfaces when the handle is used. Dropping the handle has no effect on - * the job itself. + * The returned {@link Job} answers for its own state, specification, + * result, failure and event history, so there is no separate + * connection-level call for any of them. */ - abstract job(jobId: string): Job; + abstract openJob(jobId: string): Promise; /** List server-side jobs across the database's tables. */ abstract listJobs(): Promise; - /** - * Describe a single server-side job by id. - * - * Resolves to `null` when the server has no such job. - */ - abstract getJob(jobId: string): Promise; - /** * Request cancellation of a server-side job by id. * @@ -582,13 +600,6 @@ export abstract class Connection { * such job exists. Cancelling an already-terminal job is a no-op success. */ abstract cancelJob(jobId: string): Promise; - - /** - * The lifecycle event history of a server-side job, as an Arrow table. - * - * Lists history across all jobs when `jobId` is omitted. - */ - abstract jobHistory(jobId?: string): Promise; } /** @hideconstructor */ @@ -645,6 +656,7 @@ export class LocalConnection extends Connection { select?: MaterializedViewSelect; where?: string; limit?: number; + withNoData?: boolean; }, ): Promise { validateNonNegativeInteger(options?.limit, "limit"); @@ -654,6 +666,7 @@ export class LocalConnection extends Connection { normalizeSelect(options?.select), options?.where, options?.limit, + options?.withNoData ?? false, ); return new MaterializedView(new LocalTable(innerTable)); } @@ -667,6 +680,22 @@ export class LocalConnection extends Connection { return await this.inner.listMaterializedViews(); } + async dropMaterializedView( + name: string, + namespacePath?: string[], + ): Promise { + return this.inner.dropMaterializedView(name, namespacePath ?? []); + } + + async dropMaterializedViewAsync( + name: string, + namespacePath?: string[], + ): Promise { + return new Job( + await this.inner.dropMaterializedViewAsync(name, namespacePath ?? []), + ); + } + async listTables( namespacePathOrOptions?: string[] | Partial, options?: Partial, @@ -869,7 +898,7 @@ export class LocalConnection extends Connection { } async dropTableAsync(name: string, namespacePath?: string[]): Promise { - return this.inner.dropTableAsync(name, namespacePath ?? []); + return new Job(await this.inner.dropTableAsync(name, namespacePath ?? [])); } async dropAllTables(namespacePath?: string[]): Promise { @@ -928,29 +957,17 @@ export class LocalConnection extends Connection { ); } - job(jobId: string): Job { - return this.inner.job(jobId); + async openJob(jobId: string): Promise { + return new Job(await this.inner.openJob(jobId)); } async listJobs(): Promise { return this.inner.listJobs(); } - async getJob(jobId: string): Promise { - return this.inner.getJob(jobId); - } - async cancelJob(jobId: string): Promise { return this.inner.cancelJob(jobId); } - - async jobHistory(jobId?: string): Promise { - const buf = await this.inner.jobHistory(jobId); - if (buf.length === 0) { - return new ArrowTable(); - } - return tableFromIPC(buf); - } } /** diff --git a/nodejs/lancedb/index.ts b/nodejs/lancedb/index.ts index 34d7ce4d9..39ceec917 100644 --- a/nodejs/lancedb/index.ts +++ b/nodejs/lancedb/index.ts @@ -72,11 +72,15 @@ export { export { makeArrowTable, + makeJsonField, MakeArrowTableOptions, Data, VectorColumnOptions, } from "./arrow"; +export { blob, isBlobField, BlobFile } from "./blob"; +export type { BlobOptions } from "./blob"; + export { Connection, CreateTableOptions, @@ -94,13 +98,9 @@ export { RenameTableOptions, } from "./connection"; -export { - Job, - JobDescription, - JobFailureInfo, - JobInfo, - Session, -} from "./native.js"; +export { JobFailureInfo, JobInfo, Session } from "./native.js"; + +export { Job, JobEventsOptions } from "./job"; export { AutoQuery, @@ -171,7 +171,15 @@ export { TokenResponse, } from "./header"; -export { OAuthConfig, OAuthFlowType } from "./oauth"; +export { + ClientAuthMethod, + OAuthConfig, + OAuthFlowType, + OAuthSession, + SessionLogout, + SessionStatus, + TokenCacheOptions, +} from "./oauth"; export { MergeInsertBuilder, WriteExecutionOptions } from "./merge"; @@ -621,3 +629,10 @@ export async function connectNamespace( ); return new LocalConnection(nativeConn); } + +export { + Catalog, + CatalogOptions, + ListDatabasesResponse, + connectCatalog, +} from "./catalog"; diff --git a/nodejs/lancedb/indices.ts b/nodejs/lancedb/indices.ts index dbeebf433..bc9e19300 100644 --- a/nodejs/lancedb/indices.ts +++ b/nodejs/lancedb/indices.ts @@ -26,7 +26,7 @@ export interface IvfPqOptions { * This value controls how much the vector is compressed during the quantization step. * The more sub vectors there are the less the vector is compressed. The default is * the dimension of the vector divided by 16. If the dimension is not evenly divisible - * by 16 we use the dimension divded by 8. + * by 16 we use the dimension divided by 8. * * The above two cases are highly preferred. Having 8 or 16 values per subvector allows * us to use efficient SIMD instructions. @@ -228,7 +228,7 @@ export interface HnswPqOptions { * This value controls how much the vector is compressed during the quantization step. * The more sub vectors there are the less the vector is compressed. The default is * the dimension of the vector divided by 16. If the dimension is not evenly divisible - * by 16 we use the dimension divded by 8. + * by 16 we use the dimension divided by 8. * * The above two cases are highly preferred. Having 8 or 16 values per subvector allows * us to use efficient SIMD instructions. @@ -825,7 +825,7 @@ export interface IndexOptions { /** * Advanced index configuration * - * This option allows you to specify a specfic index to create and also + * This option allows you to specify a specific index to create and also * allows you to pass in configuration for training the index. * * See the static methods on Index for details on the various index types. diff --git a/nodejs/lancedb/job.ts b/nodejs/lancedb/job.ts new file mode 100644 index 000000000..0baa0f571 --- /dev/null +++ b/nodejs/lancedb/job.ts @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +import { Table as ArrowTable, tableFromIPC } from "apache-arrow"; +import { JobFailureInfo, Job as NativeJob } from "./native"; + +/** Which of a job's events {@link Job.events} returns. */ +export interface JobEventsOptions { + /** Maximum event rows to return, up to the server maximum of 10,000. */ + limit?: number; + /** SQL-like filter over the event columns. */ + filter?: string; +} + +/** + * A handle to an operation that may still be running. + * + * The operation may already be complete when the handle is created. + * + * The detail getters read what the handle last observed. Submitting an + * operation returns only a job id, so populating them eagerly would cost an + * extra round trip on every call: + * + * - {@link Job.refresh} and {@link Job.status} fetch the whole record. + * - {@link Job.wait} records the terminal state it establishes, but not the + * rest of the record. + * - Everything is null until one of those runs. + * + * @hideconstructor + */ +export class Job { + private readonly inner: NativeJob; + + constructor(inner: NativeJob) { + this.inner = inner; + } + + /** + * Identifies the operation on the server that is running it. + * + * Operations that run in this process have no server id. The value is + * opaque: parsing it or storing it to resume the job later is not supported. + */ + get id(): string | null { + return this.inner.id ?? null; + } + + /** The last observed lifecycle state, without contacting the backend. */ + get state(): string | null { + return this.inner.state ?? null; + } + + /** + * The job's type, as the server names it. Null for an in-process job, which + * has no server-side record. + */ + get jobType(): string | null { + return this.inner.jobType ?? null; + } + + /** When the job was created, in milliseconds since the epoch. */ + get creationMs(): number | null { + return this.inner.creationMs ?? null; + } + + /** The job-type-specific specification it was submitted with. */ + // biome-ignore lint/suspicious/noExplicitAny: shape varies by job type + get spec(): any | null { + return parseJson(this.inner.specJson); + } + + /** + * The job-type-specific terminal result. Null until the job succeeds, so a + * job that never terminates reports its progress through {@link Job.events} + * instead. + */ + // biome-ignore lint/suspicious/noExplicitAny: shape varies by job type + get result(): any | null { + return parseJson(this.inner.resultJson); + } + + /** Why the job failed, when it failed and the server reports a reason. */ + get failure(): JobFailureInfo | null { + return this.inner.failure ?? null; + } + + /** + * The operation's current lifecycle state: "running", "finished", "failed", + * or "cancelled". + * + * A point snapshot; unlike {@link Job.wait} it does not block or reject on a + * terminal failure state. Also refreshes the getters above. + */ + async status(): Promise { + return this.inner.status(); + } + + /** Wait until the operation reaches a terminal state. */ + async wait(): Promise { + return this.inner.wait(); + } + + /** Request cancellation. Cancelling a finished operation is a no-op. */ + async cancel(): Promise { + return this.inner.cancel(); + } + + /** + * Ask the backend for this job's current state, and for a server-side job + * its full record, then cache it for the getters above. + */ + async refresh(): Promise { + return this.inner.refresh(); + } + + /** + * This job's recorded lifecycle events. + * + * Where the getters above report a terminal result only once the job reaches + * one, events are written as the job runs and outlive the workers that + * produced them. A distributed job records a `claim`/`claim_complete` pair + * per unit of work, each carrying `rows_processed`, so a job that never + * finishes still accounts for what it did. + * + * The server caps results at 1000 rows by default and 10,000 at most, and + * truncates without saying so, so pass `limit` for a job that emits an event + * per fragment. `filter` is a SQL-like expression over the `state`, + * `updated_by`, `emitted_from`, `emitted_by`, and `claim_entity` columns. + */ + async events(options?: JobEventsOptions): Promise { + const buf = await this.inner.events(options?.limit, options?.filter); + if (buf.length === 0) { + return new ArrowTable(); + } + return tableFromIPC(buf); + } + + /** + * Every field the handle currently knows, one per line, with the JSON + * payloads indented -- a refresh job's spec and result are the point of + * printing it. + */ + toString(): string { + if (this.state === null) { + const known = this.id === null ? "" : `id=${JSON.stringify(this.id)}, `; + return `Job(${known}not refreshed)`; + } + const fields: string[] = []; + if (this.id !== null) { + fields.push(`id=${JSON.stringify(this.id)}`); + } + fields.push(`state=${JSON.stringify(this.state)}`); + if (this.jobType !== null) { + fields.push(`jobType=${JSON.stringify(this.jobType)}`); + } + if (this.creationMs !== null) { + fields.push(`creationMs=${this.creationMs}`); + } + for (const [name, value] of [ + ["spec", this.spec], + ["result", this.result], + ] as const) { + if (value !== null) { + fields.push(`${name}=${indentJson(value)}`); + } + } + if (this.failure !== null) { + fields.push(`failure=${indentJson(this.failure)}`); + } + return `Job(${fields.map((field) => `\n${REPR_INDENT}${field},`).join("")}\n)`; + } + + [Symbol.for("nodejs.util.inspect.custom")](): string { + return this.toString(); + } +} + +const REPR_INDENT = " "; + +// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type +function indentJson(value: any): string { + return JSON.stringify(value, null, 4).replace(/\n/g, `\n${REPR_INDENT}`); +} + +// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type +function parseJson(raw: string | null | undefined): any | null { + return raw === null || raw === undefined ? null : JSON.parse(raw); +} diff --git a/nodejs/lancedb/materialized_view.ts b/nodejs/lancedb/materialized_view.ts index b26dee59e..e729c960b 100644 --- a/nodejs/lancedb/materialized_view.ts +++ b/nodejs/lancedb/materialized_view.ts @@ -19,6 +19,8 @@ export interface MaterializedViewDefinition { limit?: number; /** Source columns the projections and filter read. */ inputs: string[]; + /** Namespace holding the source table; empty is the root namespace. */ + sourceNamespace: string[]; } /** @@ -76,9 +78,22 @@ export function definitionFromMetadata( if (raw === undefined) { throw new Error(`Table '${name}' is not a materialized view`); } + return definitionFromJson(raw, name); +} + +/** @internal Parse the backend-independent definition returned by native code. */ +export function definitionFromJson( + raw: string, + name: string, +): MaterializedViewDefinition { // biome-ignore lint/suspicious/noExplicitAny: raw JSON const value: any = JSON.parse(raw); - if (value.kind !== "select") { + // "namespaced_select" keeps older readers from resolving the source at root. + if ( + value.kind !== undefined && + value.kind !== "select" && + value.kind !== "namespaced_select" + ) { throw new Error( `materialized view '${name}' is defined by '${value.kind}', which this ` + "version of lancedb cannot refresh", @@ -103,6 +118,7 @@ export function definitionFromMetadata( filter: value.filter ?? undefined, limit, inputs: value.inputs ?? [], + sourceNamespace: value.source_namespace ?? [], }; } @@ -130,10 +146,12 @@ export class MaterializedView { return this.inner; } - /** The query that defines the view, read from its stored schema. */ + /** The query that defines the view. */ async definition(): Promise { - const schema = await this.inner.schema(); - return definitionFromMetadata(schema.metadata, this.name); + return definitionFromJson( + await this.inner.materializedViewDefinition(), + this.name, + ); } /** diff --git a/nodejs/lancedb/merge.ts b/nodejs/lancedb/merge.ts index 30bde7281..5f1d8704a 100644 --- a/nodejs/lancedb/merge.ts +++ b/nodejs/lancedb/merge.ts @@ -27,7 +27,7 @@ export class MergeInsertBuilder { * but that behavior is subject to change. * * An optional condition may be specified. If it is, then only - * matched rows that satisfy the condtion will be updated. Any + * matched rows that satisfy the condition will be updated. Any * rows that do not satisfy the condition will be left as they * are. Failing to satisfy the condition does not cause a * "matched row" to become a "not matched" row. diff --git a/nodejs/lancedb/oauth.ts b/nodejs/lancedb/oauth.ts index 345eda87a..1a9cfe351 100644 --- a/nodejs/lancedb/oauth.ts +++ b/nodejs/lancedb/oauth.ts @@ -1,16 +1,79 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors +import { + OAuthConfig as NativeOAuthConfig, + OAuthSession as NativeOAuthSession, +} from "./native"; + /** * OAuth authentication flow types. */ export enum OAuthFlowType { /** Client Credentials grant (service-to-service / M2M). */ ClientCredentials = "client_credentials", + /** Interactive Authorization Code grant, using PKCE by default. */ + AuthorizationCode = "authorization_code", + /** Device Authorization grant for CLI and headless environments. */ + DeviceCode = "device_code", /** Azure Managed Identity via IMDS. */ AzureManagedIdentity = "azure_managed_identity", } +/** + * Options for the persistent OAuth token cache. + * + * The cache is opt-in: it is only used when set as `tokenCache` on + * {@link OAuthConfig}. Only refresh tokens are persisted, in a private + * directory with owner-only permissions, so short-lived processes can reuse + * an authenticated session instead of re-prompting on every start. + * + * Multiple identities (issuer, client, scopes, resource, audience, flow, client authentication) + * get separate cache entries. Within one identity the most recent login wins. + */ +export interface TokenCacheOptions { + /** + * Directory that holds cached credentials. Defaults to + * `$XDG_CACHE_HOME/lancedb/oauth`, `$HOME/.cache/lancedb/oauth` on Unix, + * or `%LOCALAPPDATA%\\lancedb\\oauth` on Windows. The directory is created + * with owner-only permissions (`0700`) when missing. + */ + cacheDir?: string; + + /** + * How long to wait for the cross-process refresh lock before failing, in + * seconds (default: 30). + */ + lockTimeoutSecs?: number; +} + +/** + * How the client authenticates to the OAuth token endpoint. + * + * The method applies to every OAuth request that carries client + * authentication: client-credentials, authorization-code exchange, + * refresh-token, and device-authorization requests. The Azure managed + * identity flow ignores this option. + */ +export enum ClientAuthMethod { + /** + * No client authentication, for public clients using PKCE or the device + * flow. Cannot be combined with `clientSecret`. + */ + None = "none", + /** + * HTTP Basic authentication. This is the RFC 6749 recommended method and + * the normal default for confidential clients, including default Okta + * applications. Requires `clientSecret`. + */ + ClientSecretBasic = "client_secret_basic", + /** + * Credentials in the request body, for providers configured to require it. + * Requires `clientSecret`. + */ + ClientSecretPost = "client_secret_post", +} + /** * OAuth configuration for LanceDB authentication. * @@ -31,6 +94,18 @@ export enum OAuthFlowType { * }; * ``` * + * Providers requiring an explicit target can set `resource` and/or `audience`: + * ```typescript + * const targeted: OAuthConfig = { + * issuerUrl: "https://issuer.example.com", + * clientId: "app-id", + * clientSecret: "secret", + * scopes: ["read"], + * resource: "https://api.example.com", + * audience: "lancedb-api", + * }; + * ``` + * * @example Azure Managed Identity: * ```typescript * const config: OAuthConfig = { @@ -40,6 +115,21 @@ export enum OAuthFlowType { * flow: OAuthFlowType.AzureManagedIdentity, * }; * ``` + * + * @example Authorization Code with PKCE: + * The authorization URL is written to stderr before LanceDB tries to open a + * browser, so it can be copied in headless environments. + * ```typescript + * const config: OAuthConfig = { + * issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", + * clientId: "app-id", + * scopes: ["openid", "api://lancedb-api/access"], + * flow: OAuthFlowType.AuthorizationCode, + * }; + * ``` + * + * Device Authorization writes the verification URL and user code to stderr + * before polling begins. */ export interface OAuthConfig { /** @@ -58,12 +148,43 @@ export interface OAuthConfig { */ scopes: string[]; + /** + * Resource indicator (RFC 8707), forwarded verbatim to authorization and token + * endpoints, including refresh requests. Must be an absolute URI without a + * fragment. Not supported for Azure managed identity. + */ + resource?: string; + + /** + * Provider-specific audience, forwarded to authorization and token endpoints, + * including refresh requests. Not supported for Azure managed identity. + */ + audience?: string; + /** Authentication flow (default: ClientCredentials). */ flow?: OAuthFlowType; /** Client secret (required for ClientCredentials). */ clientSecret?: string; + /** + * How the client authenticates to the token endpoint (default: auto). + * With a `clientSecret` the default is `ClientAuthMethod.ClientSecretBasic`, + * which matches the RFC 6749 recommendation and the default configuration + * of Okta confidential applications; without a secret the client is public + * and no client authentication is sent. + */ + clientAuthMethod?: ClientAuthMethod; + + /** Loopback redirect URI for AuthorizationCode. */ + redirectUri?: string; + + /** Port for the AuthorizationCode loopback callback server (default: 8400). */ + callbackPort?: number; + + /** Protect AuthorizationCode with S256 PKCE (default: true). */ + usePkce?: boolean; + /** Client ID for user-assigned managed identity (AzureManagedIdentity). */ managedIdentityClientId?: string; @@ -73,4 +194,125 @@ export interface OAuthConfig { * the TTL, each request refreshes the token. */ refreshBufferSecs?: number; + + /** + * Opt in to the persistent token cache so short-lived processes reuse one + * session. Only refresh tokens are persisted. Only supported by + * AuthorizationCode and DeviceCode; Azure managed identity is rejected. + * Default: unset (memory only). + */ + tokenCache?: TokenCacheOptions; +} + +/** + * Safe, non-secret view of a cached OAuth session, returned by + * {@link OAuthSession.status} and {@link OAuthSession.login}. + */ +export interface SessionStatus { + /** + * Whether a cached session exists that can obtain tokens without + * interactive authentication. Because access tokens are not persisted, + * this is `true` exactly when a refresh token is cached; the next + * connection refreshes with it rather than opening a browser or device + * prompt. + */ + refreshable: boolean; + + /** Canonical issuer URL of the cached session. */ + issuerUrl: string; + + /** Client ID of the cached session. */ + clientId: string; + + /** Canonical (sorted, de-duplicated) scope set of the cached session. */ + scopes: string[]; + + /** Resource indicator used to obtain the cached session, if configured. */ + resource?: string; + + /** Provider-specific audience used to obtain the cached session, if configured. */ + audience?: string; + + /** Flow that produced the cached session. */ + flow: string; + + /** When the cached session was obtained, as Unix seconds. */ + obtainedAt?: number; +} + +/** Result of {@link OAuthSession.logout}. */ +export interface SessionLogout { + /** + * Whether a cached credential was removed. `false` means no matching + * session was cached; logout is idempotent. + */ + removed: boolean; +} + +/** + * Explicit OAuth session lifecycle for the persistent token cache: eager + * `login`, non-secret `status`, and local `logout`. + * + * A session is built from the same {@link OAuthConfig} used to connect + * (including its `tokenCache` options). A connection created with the same + * configuration shares the cache, so logging in here prepares tokens for + * later processes without any database request. + * + * `login` always runs the configured interactive flow and replaces the cached + * session (the most recent login wins). `logout` removes only the local + * credential; it does not revoke anything with the provider and does not sign + * out of a browser SSO session. + * + * @example + * ```typescript + * const config: OAuthConfig = { + * issuerUrl: "https://issuer.example.com", + * clientId: "my-app", + * scopes: ["openid", "offline_access"], + * flow: OAuthFlowType.DeviceCode, + * tokenCache: { cacheDir: "/tmp/my-app/oauth-cache" }, + * }; + * const session = new OAuthSession(config); + * const status = await session.login(); + * ``` + */ +export class OAuthSession { + private readonly inner: NativeOAuthSession; + + /** Create a session manager for the given OAuth configuration. */ + constructor(config: OAuthConfig) { + this.inner = new NativeOAuthSession(config as unknown as NativeOAuthConfig); + } + + /** + * Eagerly run the configured authentication flow and store the session. + * + * A successful login always replaces any prior cached session for this + * identity; if the provider does not issue a refresh token (for example + * without `offline_access`), the previous record is removed and the status + * reports `refreshable == false`. + */ + async login(): Promise { + return this.inner.login(); + } + + /** + * Report whether a matching cached session exists, with safe metadata. + * + * This never contacts the identity provider and never exposes token values. + */ + async status(): Promise { + return this.inner.status(); + } + + /** + * Remove the matching local cached credential. + * + * This only deletes the local cache entry. It does not revoke the refresh + * token with the provider and does not sign out of a browser SSO session. + * Repeated calls succeed; `removed` reports whether a credential existed. + */ + async logout(): Promise { + return this.inner.logout(); + } } diff --git a/nodejs/lancedb/sanitize.ts b/nodejs/lancedb/sanitize.ts index 454c82247..3e5d583c4 100644 --- a/nodejs/lancedb/sanitize.ts +++ b/nodejs/lancedb/sanitize.ts @@ -3,7 +3,7 @@ // The utilities in this file help sanitize data from the user's arrow // library into the types expected by vectordb's arrow library. Node -// generally allows for mulitple versions of the same library (and sometimes +// generally allows for multiple versions of the same library (and sometimes // even multiple copies of the same version) to be installed at the same // time. However, arrow-js uses instanceof which expected that the input // comes from the exact same library instance. This is not always the case diff --git a/nodejs/lancedb/table.ts b/nodejs/lancedb/table.ts index dc062e337..0280ac7f8 100644 --- a/nodejs/lancedb/table.ts +++ b/nodejs/lancedb/table.ts @@ -17,8 +17,10 @@ import { tableFromIPC, } from "./arrow"; +import { BlobFile } from "./blob"; import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry"; import { IndexOptions } from "./indices"; +import { Job } from "./job"; import { MergeInsertBuilder } from "./merge"; import { AddColumnsResult, @@ -30,7 +32,6 @@ import { DropColumnsResult, IndexConfig, IndexStatistics, - Job, LsmStats, Branches as NativeBranches, OptimizeStats, @@ -147,7 +148,8 @@ export interface OptimizeOptions { * olderThan.setDate(olderThan.getDate() - 1)); * tbl.optimize({cleanupOlderThan: olderThan}); * - * // Delete all versions except the current version + * // Delete versions committed before this point. Versions created by the + * // optimize call itself are newer than the cutoff and will be retained. * tbl.optimize({cleanupOlderThan: new Date()}); */ cleanupOlderThan: Date; @@ -313,7 +315,7 @@ export abstract class Table { * Note: if your condition is something like "some_id_column == 7" and * you are updating many rows (with different ids) then you will get * better performance with a single [`merge_insert`] call instead of - * repeatedly calilng this method. + * repeatedly calling this method. * @param {Map | Record} updates - the * columns to update * @returns {Promise} A promise that resolves to an object @@ -510,6 +512,35 @@ export abstract class Table { */ abstract takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery; + /** + * Blob v2 columns, including nested dotted paths. + */ + abstract blobColumns(): Promise; + + /** + * Bytes for `column` at row IDs from {@link Query.withRowId}. + * + * Reads the table's current checkout. IDs from another version can fail after + * compaction unless stable row ids are enabled. Results keep input order and + * duplicates. Null blobs are `null`. Empty blobs are empty buffers. + */ + abstract fetchBlobs( + column: string, + rowIds: readonly (bigint | number)[], + ): Promise<(Buffer | null)[]>; + + /** + * Opens lazy blob handles for `column` at the given row IDs using the + * table's current checkout. + * + * Preserves input order, duplicates, and nulls. Use this for large payloads. + * See {@link Table.fetchBlobs} for row-ID validity across versions. + */ + abstract fetchBlobFiles( + column: string, + rowIds: readonly (bigint | number)[], + ): Promise<(BlobFile | null)[]>; + /** * Create a search query to find the nearest neighbors * of the given query @@ -542,10 +573,10 @@ export abstract class Table { * {@link Table#refreshColumn}. Declaring one therefore costs the same on a * large table as on an empty one. * - * A refresh does not revisit rows it has already filled, so mutating an - * input leaves the value computed at fill time; recomputing means dropping - * the column and declaring it again. While a declaration reads a column, - * that column cannot be renamed, retyped or dropped. + * A refresh also recomputes the rows whose inputs changed since they were + * computed, so a mutated input is reflected by the next refresh. While a + * declaration reads a column, that column cannot be renamed, retyped or + * dropped. * * On LanceDB Cloud and Enterprise the expression is planned by the * server, and the refresh runs as a server job -- see @@ -576,10 +607,10 @@ export abstract class Table { /** * Fill the rows of a computed column that hold no value yet. * - * Rows appended since the last refresh are filled by the next one; rows - * already filled are left as they are, so the call is idempotent and does - * not observe a mutated input. Local tables only: a remote refresh runs - * as a server job, through {@link Table#refreshColumnAsync}. + * Rows appended since the last refresh are filled by the next one, and + * rows whose inputs changed since they were computed are recomputed; + * everything else is left as it is. Local tables only: a remote refresh + * runs as a server job, through {@link Table#refreshColumnAsync}. * @param {string} column The name of the computed column to fill. * @returns {Promise} A promise that resolves to the * number of rows filled and the new version number of the table. @@ -609,7 +640,7 @@ export abstract class Table { * Recompute this table's contents from its materialized-view definition. * * Plumbing for {@link MaterializedView.refresh}, which is the way to call - * it: rejects tables that carry no view definition. Local tables only. + * it: rejects tables that carry no view definition. * @ignore */ abstract refreshMaterializedView( @@ -617,6 +648,9 @@ export abstract class Table { sourceVersion?: number, ): Promise; + /** @ignore */ + abstract materializedViewDefinition(): Promise; + /** * Alter the name or nullability of columns. * @param {ColumnAlteration[]} columnAlterations One or more alterations to @@ -919,6 +953,16 @@ export abstract class Table { /** Return the table as an arrow table */ abstract toArrow(): Promise; + /** + * Create a {@link MergeInsertBuilder}, which combines new data with the + * existing table in a single transaction — inserting, updating and deleting + * rows depending on how they match. + * + * @param on - The column, or columns, to match source rows against target + * rows on. Typically a key or id column. Several columns match on the + * composite key: a source row updates a target row only when it agrees on + * every one of them. + */ abstract mergeInsert(on: string | string[]): MergeInsertBuilder; /** List all the stats of a specified index @@ -1114,13 +1158,15 @@ export class LocalTable extends Table { ): Promise { // biome-ignore lint/suspicious/noExplicitAny: skip const nativeIndex = (options?.config as any)?.inner; - return await this.inner.createIndexAsync( - nativeIndex, - column, - options?.replace, - options?.waitTimeoutSeconds, - options?.name, - options?.train, + return new Job( + await this.inner.createIndexAsync( + nativeIndex, + column, + options?.replace, + options?.waitTimeoutSeconds, + options?.name, + options?.train, + ), ); } @@ -1148,23 +1194,34 @@ export class LocalTable extends Table { } takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery { - const ids = rowIds.map((id) => { - if (typeof id === "bigint") { - return id; - } - if (!Number.isInteger(id)) { - throw new Error("Row id must be an integer (or bigint)"); - } - if (id < 0) { - throw new Error("Row id cannot be negative"); - } - if (!Number.isSafeInteger(id)) { - throw new Error("Row id is too large for number; use bigint instead"); - } - return BigInt(id); - }); + return new TakeQuery(this.inner.takeRowIds(rowIdsToBigInts(rowIds))); + } - return new TakeQuery(this.inner.takeRowIds(ids)); + blobColumns(): Promise { + return this.inner.blobColumns(); + } + + async fetchBlobs( + column: string, + rowIds: readonly (bigint | number)[], + ): Promise<(Buffer | null)[]> { + const values = await this.inner.fetchBlobs(column, rowIdsToBigInts(rowIds)); + // N-API Option maps missing values to undefined. Collapse those to null. + return values.map((value) => value ?? null); + } + + async fetchBlobFiles( + column: string, + rowIds: readonly (bigint | number)[], + ): Promise<(BlobFile | null)[]> { + const files = await this.inner.fetchBlobFiles( + column, + rowIdsToBigInts(rowIds), + ); + // N-API Option maps missing values to undefined. Collapse those to null. + return files.map((file) => + file == null ? null : BlobFile.fromNative(file), + ); } query(): Query { @@ -1303,7 +1360,7 @@ export class LocalTable extends Table { } async refreshColumnAsync(column: string): Promise { - return await this.inner.refreshColumnAsync(column); + return new Job(await this.inner.refreshColumnAsync(column)); } async refreshMaterializedView( @@ -1313,6 +1370,10 @@ export class LocalTable extends Table { return await this.inner.refreshMaterializedView(full, sourceVersion); } + async materializedViewDefinition(): Promise { + return await this.inner.materializedViewDefinition(); + } + async alterColumns( columnAlterations: ColumnAlteration[], ): Promise { @@ -1433,16 +1494,8 @@ export class LocalTable extends Table { } async optimize(options?: Partial): Promise { - let cleanupOlderThanMs; - if ( - options?.cleanupOlderThan !== undefined && - options?.cleanupOlderThan !== null - ) { - cleanupOlderThanMs = - new Date().getTime() - options.cleanupOlderThan.getTime(); - } return await this.inner.optimize( - cleanupOlderThanMs, + options?.cleanupOlderThan?.getTime(), options?.deleteUnverified, ); } @@ -1721,3 +1774,21 @@ export class Branches { )) as unknown as CherryPickResult; } } + +function rowIdsToBigInts(rowIds: readonly (bigint | number)[]): bigint[] { + return rowIds.map((id) => { + if (typeof id === "bigint") { + return id; + } + if (!Number.isInteger(id)) { + throw new Error("Row id must be an integer (or bigint)"); + } + if (id < 0) { + throw new Error("Row id cannot be negative"); + } + if (!Number.isSafeInteger(id)) { + throw new Error("Row id is too large for number; use bigint instead"); + } + return BigInt(id); + }); +} diff --git a/nodejs/npm/darwin-arm64/package.json b/nodejs/npm/darwin-arm64/package.json index 2b8d43c3d..b98d8ec41 100644 --- a/nodejs/npm/darwin-arm64/package.json +++ b/nodejs/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-darwin-arm64", - "version": "0.38.0-beta.12", + "version": "0.40.0-beta.1", "os": ["darwin"], "cpu": ["arm64"], "main": "lancedb.darwin-arm64.node", diff --git a/nodejs/npm/linux-arm64-gnu/package.json b/nodejs/npm/linux-arm64-gnu/package.json index 6656fdd5c..be28e90a8 100644 --- a/nodejs/npm/linux-arm64-gnu/package.json +++ b/nodejs/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-linux-arm64-gnu", - "version": "0.38.0-beta.12", + "version": "0.40.0-beta.1", "os": ["linux"], "cpu": ["arm64"], "main": "lancedb.linux-arm64-gnu.node", diff --git a/nodejs/npm/linux-arm64-musl/package.json b/nodejs/npm/linux-arm64-musl/package.json index f8f3e151f..cc8ff6163 100644 --- a/nodejs/npm/linux-arm64-musl/package.json +++ b/nodejs/npm/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-linux-arm64-musl", - "version": "0.38.0-beta.12", + "version": "0.40.0-beta.1", "os": ["linux"], "cpu": ["arm64"], "main": "lancedb.linux-arm64-musl.node", diff --git a/nodejs/npm/linux-x64-gnu/package.json b/nodejs/npm/linux-x64-gnu/package.json index 20efa860a..1a5992ec9 100644 --- a/nodejs/npm/linux-x64-gnu/package.json +++ b/nodejs/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-linux-x64-gnu", - "version": "0.38.0-beta.12", + "version": "0.40.0-beta.1", "os": ["linux"], "cpu": ["x64"], "main": "lancedb.linux-x64-gnu.node", diff --git a/nodejs/npm/linux-x64-musl/package.json b/nodejs/npm/linux-x64-musl/package.json index 4b735a687..e838efc17 100644 --- a/nodejs/npm/linux-x64-musl/package.json +++ b/nodejs/npm/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-linux-x64-musl", - "version": "0.38.0-beta.12", + "version": "0.40.0-beta.1", "os": ["linux"], "cpu": ["x64"], "main": "lancedb.linux-x64-musl.node", diff --git a/nodejs/npm/win32-arm64-msvc/package.json b/nodejs/npm/win32-arm64-msvc/package.json index 35fee5ee0..e0543a3a6 100644 --- a/nodejs/npm/win32-arm64-msvc/package.json +++ b/nodejs/npm/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-win32-arm64-msvc", - "version": "0.38.0-beta.12", + "version": "0.40.0-beta.1", "os": [ "win32" ], diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json index 925211fbe..93d520269 100644 --- a/nodejs/npm/win32-x64-msvc/package.json +++ b/nodejs/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@lancedb/lancedb-win32-x64-msvc", - "version": "0.38.0-beta.12", + "version": "0.40.0-beta.1", "os": ["win32"], "cpu": ["x64"], "main": "lancedb.win32-x64-msvc.node", diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json deleted file mode 100644 index b996b0810..000000000 --- a/nodejs/package-lock.json +++ /dev/null @@ -1,11106 +0,0 @@ -{ - "name": "@lancedb/lancedb", - "version": "0.38.0-beta.12", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@lancedb/lancedb", - "version": "0.38.0-beta.12", - "cpu": [ - "x64", - "arm64" - ], - "license": "Apache-2.0", - "os": [ - "darwin", - "linux", - "win32" - ], - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "reflect-metadata": "^0.2.2" - }, - "devDependencies": { - "@aws-sdk/client-dynamodb": "3.1003.0", - "@aws-sdk/client-kms": "3.1003.0", - "@aws-sdk/client-s3": "3.1003.0", - "@biomejs/biome": "^1.7.3", - "@jest/globals": "^29.7.0", - "@napi-rs/cli": "3.7.0", - "@opentelemetry/sdk-metrics": "^1.30.0", - "@types/axios": "^0.14.0", - "@types/jest": "^29.1.2", - "@types/node": "22.7.4", - "@types/tmp": "^0.2.6", - "apache-arrow-15": "npm:apache-arrow@15.0.0", - "apache-arrow-16": "npm:apache-arrow@16.0.0", - "apache-arrow-17": "npm:apache-arrow@17.0.0", - "apache-arrow-18": "npm:apache-arrow@18.0.0", - "eslint": "^8.57.0", - "jest": "^29.7.0", - "shx": "^0.3.4", - "tmp": "^0.2.3", - "ts-jest": "^29.1.2", - "typedoc": "0.26.4", - "typedoc-plugin-markdown": "4.2.1", - "typescript": "5.5.4", - "typescript-eslint": "^7.1.0" - }, - "engines": { - "node": ">= 18" - }, - "optionalDependencies": { - "@huggingface/transformers": "3.0.2", - "openai": "4.29.2" - }, - "peerDependencies": { - "@types/node": ">=18", - "apache-arrow": ">=15.0.0 <=18.1.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-dynamodb": { - "version": "3.1003.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1003.0.tgz", - "integrity": "sha512-tUN5kKCvaXeXnw3nqckhRq9m3bAKsYL2WaNotYEFrKQrFW3WAEu6jxRwsRr+pasSEEYvX4B03J9tlaxfPR8rZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/credential-provider-node": "^3.972.17", - "@aws-sdk/dynamodb-codec": "^3.972.19", - "@aws-sdk/middleware-endpoint-discovery": "^3.972.7", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.3", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.8", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-retry": "^4.4.39", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.38", - "@smithy/util-defaults-mode-node": "^4.2.41", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/client-kms": { - "version": "3.1003.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.1003.0.tgz", - "integrity": "sha512-XO11qsl/p+WTzOTf4o9w6aZZ0lh2QHwwpuv9en2fgtVL4PnibndWC4Ln/5CB9fJpeUsQo8dLAys1PVhTh4lcGQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/credential-provider-node": "^3.972.17", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.3", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.8", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-retry": "^4.4.39", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.38", - "@smithy/util-defaults-mode-node": "^4.2.41", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.1003.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1003.0.tgz", - "integrity": "sha512-on8GvIWeH1pD0l53NuKbPO84bEC1mk/9zskgU+dVKcVoGxOZI94fVddCJb+IwIUN6rfBHCfXPCVbgVyzsHTAVg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/credential-provider-node": "^3.972.17", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.7", - "@aws-sdk/middleware-expect-continue": "^3.972.7", - "@aws-sdk/middleware-flexible-checksums": "^3.973.4", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-location-constraint": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-sdk-s3": "^3.972.18", - "@aws-sdk/middleware-ssec": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.3", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.8", - "@smithy/eventstream-serde-browser": "^4.2.11", - "@smithy/eventstream-serde-config-resolver": "^4.3.11", - "@smithy/eventstream-serde-node": "^4.2.11", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-blob-browser": "^4.2.12", - "@smithy/hash-node": "^4.2.11", - "@smithy/hash-stream-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/md5-js": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-retry": "^4.4.39", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.38", - "@smithy/util-defaults-mode-node": "^4.2.41", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", - "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.8.tgz", - "integrity": "sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", - "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.40", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", - "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", - "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-login": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", - "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", - "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-ini": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", - "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", - "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/token-providers": "3.1049.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", - "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.12.tgz", - "integrity": "sha512-E+qpJPN1QLzfeVDQe1gVmMiHu9PTJWwXqSQjIt8mH5OQXmds2J/IN+Ar6Oa9ZhhuPZb4fPkcgZg4UEpwJM90NA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.5.tgz", - "integrity": "sha512-itVdge0NozgtgmtbZ25FVwWU3vGlE7x7feE/aOEJNkQfEpbkrF8Rj1QmnK+2blFfYE1xWt/iU+6/jUp/pv1+MA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.14.tgz", - "integrity": "sha512-Aaj0d+xbo1jJquBWJP0/9V/XZRYukO3LWIRp3dOLHmoFrYKb4YZ0aLefgVHfGcNOVBS2ZTq7L/n5JcrE7DaC+Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.13.tgz", - "integrity": "sha512-1r6EkFdSQ4quTP3pW8yWIcYuyDwdwdBxGr+kfuPFYE3DqR+1gBc6NyJneAyoIs+wc/cUfnyJ4ZYC0T2SQTxP9A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.5", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.12.tgz", - "integrity": "sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.20.tgz", - "integrity": "sha512-NdnMVQCR1YjIcqFAiNLdBiOwr2DyQDB2IiXQrBhzolKOv32ae4d4Ll7IzLMi04eMHiq/o/Y/GjFuVjF9HuG0QA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/crc64-nvme": "^3.972.8", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.13.tgz", - "integrity": "sha512-EA3+u2LD3kGcfRNmCSjyJuzX4XvG4zYv57i4ZksH+1IEciuSyHQGvzivEz7vZ+jbRPdAAe7WWKy/4M8InCKDcw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz", - "integrity": "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.12.tgz", - "integrity": "sha512-NxB2dS4/mV3380hNkC72TkhMaLLjWGGBeTAEucqlJptVVovTbNmQWZLwaMC74ICo9NZHmFiBVVTHzDfAh/3y6Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.14.tgz", - "integrity": "sha512-bqL+upATpOJ/7px4IVfMVxcM6Lyt9uRizmEx3mNg4N6+IQlnOaYXXOJ4TNX6P0mKPPW0lwn9ZW8QEhXwQuCH9A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.41.tgz", - "integrity": "sha512-M4T2I2WPuH5WQpU8Tsp+u2bcO29zGRkU14ATzuqb9I4xh8tzsLqtp4hzaJM5aO2dhMZnHDzyQwSFVgc3XbnoGg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz", - "integrity": "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.42.tgz", - "integrity": "sha512-U7jjlJKQnuUlI2swC2umFLFzLAxMLudSRFv+Bqk2F8ORmr5bG25qsFxGm4GEFwoZeGaFFnAFmTY0xReVRfyl2A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", - "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.16.tgz", - "integrity": "sha512-/YaivCvKUkEeMN9VTKBSvBn5w/4osAM1YboM58DKaLF/vqFGf/FdJCLmppqiPPJWZaXcASqByVjc3evE7KHKdA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", - "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.11.tgz", - "integrity": "sha512-BUMJ6VoL54r6Udj/wKy8uKRIndL04rGbaS/wTIV0dM1ewxSrR8yARBHdvZKQsK55ZSW2JrmAPk3KP15kBDxJMw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@smithy/core": "^3.24.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.13.tgz", - "integrity": "sha512-wfk9ZdVwh187gdGXB1EyAoprwjSMt/bSfVtva+OaZx+LyNdKD7smlZf611yMd42UpfQ9vaS8NOftjSajgpdd+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.28.tgz", - "integrity": "sha512-A2l/PTRzsOS9L8dmZbXtDyJQgeeX+qjqLJ+fr0UU5Dz0AUQMuxgZCPSLKZgUDlHAmLFuk34owdMEvJxmDTBgRg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@biomejs/biome": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", - "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", - "dev": true, - "hasInstallScript": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.9.4", - "@biomejs/cli-darwin-x64": "1.9.4", - "@biomejs/cli-linux-arm64": "1.9.4", - "@biomejs/cli-linux-arm64-musl": "1.9.4", - "@biomejs/cli-linux-x64": "1.9.4", - "@biomejs/cli-linux-x64-musl": "1.9.4", - "@biomejs/cli-win32-arm64": "1.9.4", - "@biomejs/cli-win32-x64": "1.9.4" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", - "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@huggingface/jinja": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.3.4.tgz", - "integrity": "sha512-kFFQWJiWwvxezKQnvH1X7GjsECcMljFx+UZK9hx6P26aVHwwidJVTB0ptLfRVZQvVkOGHoMmTGvo4nT0X9hHOA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@huggingface/transformers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.0.2.tgz", - "integrity": "sha512-lTyS81eQazMea5UCehDGFMfdcNRZyei7XQLH5X6j4AhA/18Ka0+5qPgMxUxuZLU4xkv60aY2KNz9Yzthv6WVJg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@huggingface/jinja": "^0.3.0", - "onnxruntime-node": "1.19.2", - "onnxruntime-web": "1.21.0-dev.20241024-d9ca84ef96", - "sharp": "^0.33.5" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.2.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@inquirer/ansi": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", - "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.5.tgz", - "integrity": "sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.10", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.13.tgz", - "integrity": "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "11.1.10", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz", - "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.2.tgz", - "integrity": "sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/external-editor": "^3.0.0", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.14.tgz", - "integrity": "sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", - "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", - "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.13.tgz", - "integrity": "sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.13.tgz", - "integrity": "sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.13.tgz", - "integrity": "sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "8.4.3", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.3.tgz", - "integrity": "sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^5.1.5", - "@inquirer/confirm": "^6.0.13", - "@inquirer/editor": "^5.1.2", - "@inquirer/expand": "^5.0.14", - "@inquirer/input": "^5.0.13", - "@inquirer/number": "^4.0.13", - "@inquirer/password": "^5.0.13", - "@inquirer/rawlist": "^5.2.9", - "@inquirer/search": "^4.1.9", - "@inquirer/select": "^5.1.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "5.2.9", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.9.tgz", - "integrity": "sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.9.tgz", - "integrity": "sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.5.tgz", - "integrity": "sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.10", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", - "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/cli": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.7.0.tgz", - "integrity": "sha512-3d3+rmxlOIV/G1zPWeX4PCxuYnhcCQM2BvY9rtimC8RO0dFR9gtYP+Grov+WoduZtfWRj5N1XvytWeRxxCk5zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/prompts": "^8.0.0", - "@napi-rs/cross-toolchain": "^1.0.3", - "@napi-rs/wasm-tools": "^1.0.1", - "@octokit/rest": "^22.0.1", - "clipanion": "^4.0.0-rc.4", - "colorette": "^2.0.20", - "emnapi": "^1.10.0", - "es-toolkit": "^1.41.0", - "js-yaml": "^4.1.0", - "obug": "^2.0.0", - "semver": "^7.7.3", - "typanion": "^3.14.0" - }, - "bin": { - "napi": "dist/cli.js", - "napi-raw": "cli.mjs" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/runtime": "^1.7.1" - }, - "peerDependenciesMeta": { - "@emnapi/runtime": { - "optional": true - } - } - }, - "node_modules/@napi-rs/cross-toolchain": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@napi-rs/cross-toolchain/-/cross-toolchain-1.0.3.tgz", - "integrity": "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==", - "dev": true, - "license": "MIT", - "workspaces": [ - ".", - "arm64/*", - "x64/*" - ], - "dependencies": { - "@napi-rs/lzma": "^1.4.5", - "@napi-rs/tar": "^1.1.0", - "debug": "^4.4.1" - }, - "peerDependencies": { - "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", - "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", - "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", - "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", - "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", - "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" - }, - "peerDependenciesMeta": { - "@napi-rs/cross-toolchain-arm64-target-aarch64": { - "optional": true - }, - "@napi-rs/cross-toolchain-arm64-target-armv7": { - "optional": true - }, - "@napi-rs/cross-toolchain-arm64-target-ppc64le": { - "optional": true - }, - "@napi-rs/cross-toolchain-arm64-target-s390x": { - "optional": true - }, - "@napi-rs/cross-toolchain-arm64-target-x86_64": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-aarch64": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-armv7": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-ppc64le": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-s390x": { - "optional": true - }, - "@napi-rs/cross-toolchain-x64-target-x86_64": { - "optional": true - } - } - }, - "node_modules/@napi-rs/lzma": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.4.5.tgz", - "integrity": "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/lzma-android-arm-eabi": "1.4.5", - "@napi-rs/lzma-android-arm64": "1.4.5", - "@napi-rs/lzma-darwin-arm64": "1.4.5", - "@napi-rs/lzma-darwin-x64": "1.4.5", - "@napi-rs/lzma-freebsd-x64": "1.4.5", - "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", - "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", - "@napi-rs/lzma-linux-arm64-musl": "1.4.5", - "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", - "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", - "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", - "@napi-rs/lzma-linux-x64-gnu": "1.4.5", - "@napi-rs/lzma-linux-x64-musl": "1.4.5", - "@napi-rs/lzma-wasm32-wasi": "1.4.5", - "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", - "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", - "@napi-rs/lzma-win32-x64-msvc": "1.4.5" - } - }, - "node_modules/@napi-rs/lzma-android-arm-eabi": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.4.5.tgz", - "integrity": "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-android-arm64": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.4.5.tgz", - "integrity": "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-darwin-arm64": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.4.5.tgz", - "integrity": "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-darwin-x64": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.4.5.tgz", - "integrity": "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-freebsd-x64": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.4.5.tgz", - "integrity": "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.4.5.tgz", - "integrity": "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-linux-arm64-gnu": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.4.5.tgz", - "integrity": "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-linux-arm64-musl": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.4.5.tgz", - "integrity": "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.4.5.tgz", - "integrity": "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.4.5.tgz", - "integrity": "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-linux-s390x-gnu": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.4.5.tgz", - "integrity": "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.4.5.tgz", - "integrity": "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-musl": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.4.5.tgz", - "integrity": "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-wasm32-wasi": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.4.5.tgz", - "integrity": "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@napi-rs/lzma-win32-arm64-msvc": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.4.5.tgz", - "integrity": "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-win32-ia32-msvc": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.4.5.tgz", - "integrity": "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/lzma-win32-x64-msvc": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.4.5.tgz", - "integrity": "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.0.tgz", - "integrity": "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/tar-android-arm-eabi": "1.1.0", - "@napi-rs/tar-android-arm64": "1.1.0", - "@napi-rs/tar-darwin-arm64": "1.1.0", - "@napi-rs/tar-darwin-x64": "1.1.0", - "@napi-rs/tar-freebsd-x64": "1.1.0", - "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", - "@napi-rs/tar-linux-arm64-gnu": "1.1.0", - "@napi-rs/tar-linux-arm64-musl": "1.1.0", - "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", - "@napi-rs/tar-linux-s390x-gnu": "1.1.0", - "@napi-rs/tar-linux-x64-gnu": "1.1.0", - "@napi-rs/tar-linux-x64-musl": "1.1.0", - "@napi-rs/tar-wasm32-wasi": "1.1.0", - "@napi-rs/tar-win32-arm64-msvc": "1.1.0", - "@napi-rs/tar-win32-ia32-msvc": "1.1.0", - "@napi-rs/tar-win32-x64-msvc": "1.1.0" - } - }, - "node_modules/@napi-rs/tar-android-arm-eabi": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.0.tgz", - "integrity": "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-android-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.0.tgz", - "integrity": "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.0.tgz", - "integrity": "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-freebsd-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.0.tgz", - "integrity": "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.0.tgz", - "integrity": "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-arm64-gnu": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.0.tgz", - "integrity": "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-arm64-musl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.0.tgz", - "integrity": "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-ppc64-gnu": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.0.tgz", - "integrity": "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-s390x-gnu": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.0.tgz", - "integrity": "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-x64-gnu": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-gnu/-/tar-linux-x64-gnu-1.1.0.tgz", - "integrity": "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-linux-x64-musl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-musl/-/tar-linux-x64-musl-1.1.0.tgz", - "integrity": "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-wasm32-wasi": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.0.tgz", - "integrity": "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@napi-rs/tar-win32-arm64-msvc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.0.tgz", - "integrity": "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-win32-ia32-msvc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.0.tgz", - "integrity": "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/tar-win32-x64-msvc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.0.tgz", - "integrity": "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@napi-rs/wasm-tools": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools/-/wasm-tools-1.0.1.tgz", - "integrity": "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", - "@napi-rs/wasm-tools-android-arm64": "1.0.1", - "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", - "@napi-rs/wasm-tools-darwin-x64": "1.0.1", - "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", - "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", - "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", - "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", - "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", - "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", - "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", - "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", - "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" - } - }, - "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.0.1.tgz", - "integrity": "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.0.1.tgz", - "integrity": "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.0.1.tgz", - "integrity": "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-gnu/-/wasm-tools-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-musl/-/wasm-tools-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.0.1.tgz", - "integrity": "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@octokit/auth-token": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/core": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/endpoint": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", - "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", - "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-request-log": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", - "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", - "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/request": { - "version": "10.0.9", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.9.tgz", - "integrity": "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.3", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "content-type": "^2.0.0", - "fast-content-type-parse": "^3.0.0", - "json-with-bigint": "^3.5.3", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/rest": { - "version": "22.0.1", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", - "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/core": "^7.0.6", - "@octokit/plugin-paginate-rest": "^14.0.0", - "@octokit/plugin-request-log": "^6.0.0", - "@octokit/plugin-rest-endpoint-methods": "^17.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", - "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.28.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", - "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz", - "integrity": "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/resources": "1.30.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@shikijs/core": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", - "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/engine-javascript": "1.29.2", - "@shikijs/engine-oniguruma": "1.29.2", - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz", - "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "oniguruma-to-es": "^2.2.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz", - "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1" - } - }, - "node_modules/@shikijs/langs": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz", - "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2" - } - }, - "node_modules/@shikijs/themes": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz", - "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2" - } - }, - "node_modules/@shikijs/types": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz", - "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.1", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.5.3.tgz", - "integrity": "sha512-TpS6Am5zSEtx3ow7VynThEL7UwRM06zZZcmFaP6Ij9hqKPfsFhTYCLcgU7gjFjw9QAI2kzwXrfS7InH8BivJTA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.3.3.tgz", - "integrity": "sha512-LXg5yYJPYnVSrpa6LOZ+/wqpI2OlIccy7j5F16EFNYDbXWmnhry/PFRRPyM30H+hJeqfVgckFuvNGnAGCt56cA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.4.3.tgz", - "integrity": "sha512-MdQxEX5SFNc3QmpiLXtcZXsWk4imCfGVN7Ikz9I/XvavypvHT4mqxwo5JHdr/LBKCfAv89+8193ZWlUwDp8YXQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.3.3.tgz", - "integrity": "sha512-54RbRsw9eVaVnqYUXi3F6nMAPgUyKsBvAKBY2lf+81mIgM7N+yS9V5LYk7yUGbrM789b2e1qBuyDSjX1/Axxcw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.3.3.tgz", - "integrity": "sha512-TkGfDlYeWOGwYvAunHHHmKgvFtD7DFAl6gWxATI4pv4B6w0Wnx6RK5zCMoXTTqMVd+zPcWm7w8RPTgHytoCDJA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.3.3.tgz", - "integrity": "sha512-tSUA38sM7kzMoLhqQ2aCGTwLXovjurz3jjG+a0sxqD4qT/4FhQr/wxMdhCumT70giM+axC1pPjimAHLlEQCfzw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.3.3.tgz", - "integrity": "sha512-ZyDAlpKKc7BKHUp+kDBiTwNhiHrOf3syQdvQadvnwWs0QJhYMHMg6QSarlhpzN6qr+KBFM/oF/xP/bvzR6KI9w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.3.3.tgz", - "integrity": "sha512-wUWowbCm7DGczl6bfLI6wGGtoxwN5Pon8DhF0Q8AA4NvgLwYfLo3h2DWI7sHr33lLcEsyTLQKeUeTHydqSfQ5Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/md5-js": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.3.3.tgz", - "integrity": "sha512-pFw8gEMrHw9BbRwNm//UU4WgnVO7+dhfFRaSAkFPfwslWU2LXt0mM+oap3iFwGbdD8kuAWIeOAxqSiamOcM3Dw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.3.3.tgz", - "integrity": "sha512-Up1XAYnj6oxFBypWpkhNpgX+yReQxkKAV/iLaeP0KVLb2oTkmA9X+UJuGBVvEA9uZIN06y0irDi7sBMuTZMVJg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.5.3.tgz", - "integrity": "sha512-p60HGFflWsJC6V9GAYeFgbfORn+9ILx8FqgMa/8PzA0rhIUxF57EKoOR4Irs6oe1oy8RLzhjhcGS8CBtPv/t+Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.6.3.tgz", - "integrity": "sha512-MnfYnJs3cBXK3ZBqbPzXRPHIp+QtgpkX5NogcUOWHPU5GbgTAQSIfPLi91lTcEbkFDcH2YbgjLPQjWeyQ689rA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.3.3.tgz", - "integrity": "sha512-RUVCZgn92izDAARs5OJSM2+KWSfTRvQWwN9t0MmiybT3pquRgDx9vD9t/YZjd/5lwcFbsNuPojJSddYQEZGeWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.3.3.tgz", - "integrity": "sha512-+BPabWluqxo3EfMMvOgnAmPtWnCSzj+gf5mJ27wTZUbvS0hpdUIU1g80R01bEGKZx4JCi8P58jAXD9FUGMjhwA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.4.3.tgz", - "integrity": "sha512-vDtz5OuytrjP4o9GtAOz1JloN003p94utJIQeO0WAjorhpafFFjpbDOrP6btPoCN3UxaU/U84OIEt5dM7ZRRLA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.4.3.tgz", - "integrity": "sha512-P16TBD/d8ZcD9MHQ0ubQ9BbOYSd5HZKbHOLsyFWxKk2oBEoghbRFPfGOoqToZX1yrfLITXRylL16EyPP4IzLPg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.13.3", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.13.3.tgz", - "integrity": "sha512-Z8mQ+YryjP5krDadV6unnp5035L4S1brafXpTiRmjPweKSaQ6X9CYDYWvmEggXjDIa1oufX/2a/bdwu8EIz/lw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.3.3.tgz", - "integrity": "sha512-TsMTAOnjuMOv1zJBw8cfYGWhopyc3og8tZX/KuyCPjg7V3ji3f4YjFOVu843UjBmrfS/+X6kwFv5ZKg7sSm1bQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.3.tgz", - "integrity": "sha512-91lxjhFpAktA9yPBxniqVR/NSH9zyjMjLmoa+jbQHQFR9WiJA+n61T7HBrfh5APdEoAledJwGq8l4cS+ZJFUnQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.3.3.tgz", - "integrity": "sha512-/M6Ya1Fjq8hg3rYjiwwqTen6s1bAa3U3g/2eicBaBQfaoa4ymLUke/x4T8mwb9dSq/L8TQ4YgndS0MaB9ShgmA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.3.3.tgz", - "integrity": "sha512-M+zdSrevWj0grtZx2RBULPUyjTq1aB+n+13Hrm9owiGpow6DqY/WqiSj6sHVQy/rKp0j7NzV3TNf2LrwDel8JQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.4.3.tgz", - "integrity": "sha512-Q60hxKkMEkmBsOEzxlMWEymBWov0dtWGgoJhOUs6mE8k2FDPjK8NlsRdMkmO80n2pwzreHtrYcX5jiRP7ZkP3w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.3.3.tgz", - "integrity": "sha512-RYj+8gr95WiiBqvVghoRvL12NS9ryvLyufp7FOs7EzKwGX0W5gOVlXdCrFkJScSf8gxdjQMRyIZ3Y82/MvXQ3Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.5.3.tgz", - "integrity": "sha512-2JqSmzQtKDKqBckLl/9NXTL1fY+zQBU5fNGMpud7AT65vql0tVFhb2UEZNZmLSHayLeD+X/Qzn84oXw5KS+KSQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.3.3.tgz", - "integrity": "sha512-8NZwlQ+nyAIWn9YZxH14FC8ca0i6ZGW1aJyPjD+zMZz3k9jOhXXKhdCSRvjmcSYLW42uhbrxavXqMkrTKHyY3A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.4.3.tgz", - "integrity": "sha512-8RJXeU5lEhdNfXm4XAuHlf6VtNzd279Z2FJZSR7VaELYCR46ffgjJBSjc+3UAy7V1YqBOLV0G9gWhLB/nA44nA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.6.3.tgz", - "integrity": "sha512-DSpJpPg0rQwjZk9/CSlOTplD6xSUu+bz8eDJQkq/Fmy9JlSD4ZGhXG/qFl0aRHmouDbBF75tnZ00lPxiL/sgRQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.3.3.tgz", - "integrity": "sha512-c1QpRBn3aMsoqE64dd4Imgjy8Pynfw+eR7GkjElquxUFSnezwYVaOFm8JcYa+Bo/5ssbEyPKcT3+4bmrWYh6eQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.4.3.tgz", - "integrity": "sha512-WSHSF865zDGFGtJdMmYPI2Blq/MbUrn5CB4bLDg4ARbQ9z7oA87ZZ/FSiwNZbQrU/EiVyl9lpINswALgI4lZXA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/axios": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz", - "integrity": "sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==", - "deprecated": "This is a stub types definition. axios provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "axios": "*" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/command-line-args": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", - "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", - "license": "MIT" - }, - "node_modules/@types/command-line-usage": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", - "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", - "license": "MIT" - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/node": { - "version": "22.7.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", - "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", - "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "optional": true, - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/apache-arrow": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.1.0.tgz", - "integrity": "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/command-line-args": "^5.2.3", - "@types/command-line-usage": "^5.0.4", - "@types/node": "^20.13.0", - "command-line-args": "^5.2.1", - "command-line-usage": "^7.0.1", - "flatbuffers": "^24.3.25", - "json-bignum": "^0.0.3", - "tslib": "^2.6.2" - }, - "bin": { - "arrow2csv": "bin/arrow2csv.js" - } - }, - "node_modules/apache-arrow-15": { - "name": "apache-arrow", - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-15.0.0.tgz", - "integrity": "sha512-e6aunxNKM+woQf137ny3tp/xbLjFJS2oGQxQhYGqW6dGeIwNV1jOeEAeR6sS2jwAI2qLO83gYIP2MBz02Gw5Xw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.2", - "@types/command-line-args": "^5.2.1", - "@types/command-line-usage": "^5.0.2", - "@types/node": "^20.6.0", - "command-line-args": "^5.2.1", - "command-line-usage": "^7.0.1", - "flatbuffers": "^23.5.26", - "json-bignum": "^0.0.3", - "tslib": "^2.6.2" - }, - "bin": { - "arrow2csv": "bin/arrow2csv.cjs" - } - }, - "node_modules/apache-arrow-15/node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/apache-arrow-15/node_modules/flatbuffers": { - "version": "23.5.26", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-23.5.26.tgz", - "integrity": "sha512-vE+SI9vrJDwi1oETtTIFldC/o9GsVKRM+s6EL0nQgxXlYV1Vc4Tk30hj4xGICftInKQKj1F3up2n8UbIVobISQ==", - "dev": true, - "license": "SEE LICENSE IN LICENSE" - }, - "node_modules/apache-arrow-15/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/apache-arrow-16": { - "name": "apache-arrow", - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-16.0.0.tgz", - "integrity": "sha512-bVyJeV4ahJW4XYjXefSBco0/mSSSElOzzh3Qx7tsKH+94sZaHrRotKKj1xVjON1hMUm7TODi6DnbFE73Q2h2MA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.2", - "@types/command-line-args": "^5.2.1", - "@types/command-line-usage": "^5.0.2", - "@types/node": "^20.6.0", - "command-line-args": "^5.2.1", - "command-line-usage": "^7.0.1", - "flatbuffers": "^23.5.26", - "json-bignum": "^0.0.3", - "tslib": "^2.6.2" - }, - "bin": { - "arrow2csv": "bin/arrow2csv.cjs" - } - }, - "node_modules/apache-arrow-16/node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/apache-arrow-16/node_modules/flatbuffers": { - "version": "23.5.26", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-23.5.26.tgz", - "integrity": "sha512-vE+SI9vrJDwi1oETtTIFldC/o9GsVKRM+s6EL0nQgxXlYV1Vc4Tk30hj4xGICftInKQKj1F3up2n8UbIVobISQ==", - "dev": true, - "license": "SEE LICENSE IN LICENSE" - }, - "node_modules/apache-arrow-16/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/apache-arrow-17": { - "name": "apache-arrow", - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-17.0.0.tgz", - "integrity": "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/command-line-args": "^5.2.3", - "@types/command-line-usage": "^5.0.4", - "@types/node": "^20.13.0", - "command-line-args": "^5.2.1", - "command-line-usage": "^7.0.1", - "flatbuffers": "^24.3.25", - "json-bignum": "^0.0.3", - "tslib": "^2.6.2" - }, - "bin": { - "arrow2csv": "bin/arrow2csv.cjs" - } - }, - "node_modules/apache-arrow-17/node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/apache-arrow-17/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/apache-arrow-18": { - "name": "apache-arrow", - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.0.0.tgz", - "integrity": "sha512-gFlPaqN9osetbB83zC29AbbZqGiCuFH1vyyPseJ+B7SIbfBtESV62mMT/CkiIt77W6ykC/nTWFzTXFs0Uldg4g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/command-line-args": "^5.2.3", - "@types/command-line-usage": "^5.0.4", - "@types/node": "^20.13.0", - "command-line-args": "^5.2.1", - "command-line-usage": "^7.0.1", - "flatbuffers": "^24.3.25", - "json-bignum": "^0.0.3", - "tslib": "^2.6.2" - }, - "bin": { - "arrow2csv": "bin/arrow2csv.js" - } - }, - "node_modules/apache-arrow-18/node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/apache-arrow-18/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/apache-arrow/node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/apache-arrow/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT", - "peer": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-back": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", - "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base-64": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", - "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==", - "optional": true - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", - "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk-template": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", - "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/chalk-template?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": "*" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/clipanion": { - "version": "4.0.0-rc.4", - "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", - "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", - "dev": true, - "license": "MIT", - "workspaces": [ - "website" - ], - "dependencies": { - "typanion": "^3.8.0" - }, - "peerDependencies": { - "typanion": "*" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/command-line-args": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", - "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", - "license": "MIT", - "dependencies": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", - "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-usage": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", - "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", - "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "chalk-template": "^0.4.0", - "table-layout": "^4.1.1", - "typical": "^7.3.0" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/command-line-usage/node_modules/array-back": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", - "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/command-line-usage/node_modules/typical": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", - "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dedent": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/digest-fetch": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", - "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", - "license": "ISC", - "optional": true, - "dependencies": { - "base-64": "^0.1.0", - "md5": "^2.3.0" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.357", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", - "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emnapi": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/emnapi/-/emnapi-1.10.0.tgz", - "integrity": "sha512-swoyZjupDvLoe/KC3HZ4SY1JUN+tviT6eOZ3Px28TZAYdBHtRIiMWWrIUUH+2/9CYY4fNTID1YhYZ+kdFHszHg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "node-addon-api": ">= 6.1.0" - }, - "peerDependenciesMeta": { - "node-addon-api": { - "optional": true - } - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", - "dev": true, - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", - "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", - "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", - "license": "MIT", - "dependencies": { - "array-back": "^3.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatbuffers": { - "version": "24.12.23", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz", - "integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==", - "license": "Apache-2.0" - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT", - "optional": true - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, - "node_modules/formdata-node/node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/guid-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", - "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "license": "ISC", - "optional": true - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT", - "optional": true - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-bignum": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", - "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-with-bigint": { - "version": "3.5.8", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", - "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT" - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0", - "optional": true - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lunr": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", - "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mnemonist": { - "version": "0.38.3", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", - "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "obliterator": "^1.6.1" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/obliterator": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", - "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", - "dev": true, - "license": "MIT" - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/oniguruma-to-es": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", - "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^5.1.1", - "regex-recursion": "^5.1.1" - } - }, - "node_modules/onnxruntime-common": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.19.2.tgz", - "integrity": "sha512-a4R7wYEVFbZBlp0BfhpbFWqe4opCor3KM+5Wm22Az3NGDcQMiU2hfG/0MfnBs+1ZrlSGmlgWeMcXQkDk1UFb8Q==", - "license": "MIT", - "optional": true - }, - "node_modules/onnxruntime-node": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.19.2.tgz", - "integrity": "sha512-9eHMP/HKbbeUcqte1JYzaaRC8JPn7ojWeCeoyShO86TOR97OCyIyAIOGX3V95ErjslVhJRXY8Em/caIUc0hm1Q==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "win32", - "darwin", - "linux" - ], - "dependencies": { - "onnxruntime-common": "1.19.2", - "tar": "^7.0.1" - } - }, - "node_modules/onnxruntime-web": { - "version": "1.21.0-dev.20241024-d9ca84ef96", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.21.0-dev.20241024-d9ca84ef96.tgz", - "integrity": "sha512-ANSQfMALvCviN3Y4tvTViKofKToV1WUb2r2VjZVCi3uUBPaK15oNJyIxhsNyEckBr/Num3JmSXlkHOD8HfVzSQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "flatbuffers": "^1.12.0", - "guid-typescript": "^1.0.9", - "long": "^5.2.3", - "onnxruntime-common": "1.20.0-dev.20241016-2b8fc5529b", - "platform": "^1.3.6", - "protobufjs": "^7.2.4" - } - }, - "node_modules/onnxruntime-web/node_modules/flatbuffers": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", - "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true - }, - "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { - "version": "1.20.0-dev.20241016-2b8fc5529b", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.20.0-dev.20241016-2b8fc5529b.tgz", - "integrity": "sha512-KZK8b6zCYGZFjd4ANze0pqBnqnFTS3GIVeclQpa2qseDpXrCQJfkWBixRcrZShNhm3LpFOZ8qJYFC5/qsJK9WQ==", - "license": "MIT", - "optional": true - }, - "node_modules/openai": { - "version": "4.29.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.29.2.tgz", - "integrity": "sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "digest-fetch": "^1.3.0", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7", - "web-streams-polyfill": "^3.2.1" - }, - "bin": { - "openai": "bin/cli" - } - }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "optional": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT", - "optional": true - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/platform": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", - "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "license": "MIT", - "optional": true - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/protobufjs": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", - "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", - "dev": true, - "dependencies": { - "resolve": "^1.1.6" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, - "node_modules/regex": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", - "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", - "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex": "^5.1.1", - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "devOptional": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shelljs": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", - "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "glob": "^7.0.0", - "interpret": "^1.0.0", - "rechoir": "^0.6.2" - }, - "bin": { - "shjs": "bin/shjs" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/shiki": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", - "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "1.29.2", - "@shikijs/engine-javascript": "1.29.2", - "@shikijs/engine-oniguruma": "1.29.2", - "@shikijs/langs": "1.29.2", - "@shikijs/themes": "1.29.2", - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "@types/hast": "^3.0.4" - } - }, - "node_modules/shx": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", - "integrity": "sha512-N6A9MLVqjxZYcVn8hLmtneQWIJtp8IKzMP4eMnx+nqkvXoqinUPCbUFLp2UcWTEIUONhlk0ewxr/jaVGlc+J+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.3", - "shelljs": "^0.8.5" - }, - "bin": { - "shx": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT", - "optional": true - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/table-layout": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", - "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", - "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "wordwrapjs": "^5.1.0" - }, - "engines": { - "node": ">=12.17" - } - }, - "node_modules/table-layout/node_modules/array-back": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", - "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-jest": { - "version": "29.4.9", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", - "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.9", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.4", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <7" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typanion": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", - "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", - "dev": true, - "license": "MIT", - "workspaces": [ - "website" - ] - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typedoc": { - "version": "0.26.4", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.4.tgz", - "integrity": "sha512-FlW6HpvULDKgc3rK04V+nbFyXogPV88hurarDPOjuuB5HAwuAlrCMQ5NeH7Zt68a/ikOKu6Z/0hFXAeC9xPccQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "lunr": "^2.3.9", - "markdown-it": "^14.1.0", - "minimatch": "^9.0.5", - "shiki": "^1.9.1", - "yaml": "^2.4.5" - }, - "bin": { - "typedoc": "bin/typedoc" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x" - } - }, - "node_modules/typedoc-plugin-markdown": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.2.1.tgz", - "integrity": "sha512-7hQt/1WaW/VI4+x3sxwcCGsEylP1E1GvF6OTTELK5sfTEp6AeK+83jkCOgZGp1pI2DiOammMYQMnxxOny9TKsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "typedoc": "0.26.x" - } - }, - "node_modules/typedoc/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/typedoc/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-7.18.0.tgz", - "integrity": "sha512-PonBkP603E3tt05lDkbOMyaxJjvKqQrXsnow72sVeOFINDE/qNmnnd+f9b4N+U7W6MXnnYyrhtmF2t08QWwUbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "7.18.0", - "@typescript-eslint/parser": "7.18.0", - "@typescript-eslint/utils": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/typical": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", - "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/universal-user-agent": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", - "dev": true, - "license": "ISC" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wordwrapjs": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", - "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/nodejs/package.json b/nodejs/package.json index 19c7f4d32..3e8725c35 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,7 +11,7 @@ "ann" ], "private": false, - "version": "0.38.0-beta.12", + "version": "0.40.0-beta.1", "main": "dist/index.js", "exports": { ".": "./dist/index.js", @@ -44,7 +44,7 @@ "@biomejs/biome": "^1.7.3", "@jest/globals": "^29.7.0", "@napi-rs/cli": "3.7.0", - "@opentelemetry/sdk-metrics": "^1.30.0", + "@opentelemetry/sdk-metrics": "^2.10.0", "@types/axios": "^0.14.0", "@types/jest": "^29.1.2", "@types/node": "22.7.4", @@ -56,7 +56,7 @@ "eslint": "^8.57.0", "jest": "^29.7.0", "shx": "^0.3.4", - "tmp": "^0.2.3", + "tmp": "^0.2.7", "ts-jest": "^29.1.2", "typedoc": "0.26.4", "typedoc-plugin-markdown": "4.2.1", @@ -67,7 +67,7 @@ "timeout": "3m" }, "engines": { - "node": ">= 18" + "node": ">= 22" }, "packageManager": "pnpm@11.1.1", "cpu": ["x64", "arm64"], @@ -101,7 +101,7 @@ "openai": "4.29.2" }, "peerDependencies": { - "@types/node": ">=18", + "@types/node": ">=22", "apache-arrow": ">=15.0.0 <=18.1.0" }, "peerDependenciesMeta": { diff --git a/nodejs/pnpm-lock.yaml b/nodejs/pnpm-lock.yaml index c21c636d2..a03838999 100644 --- a/nodejs/pnpm-lock.yaml +++ b/nodejs/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + sharp: ^0.35.4 + importers: .: @@ -35,10 +38,10 @@ importers: version: 29.7.0 '@napi-rs/cli': specifier: 3.7.0 - version: 3.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.7.4) + version: 3.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@types/node@22.7.4) '@opentelemetry/sdk-metrics': - specifier: ^1.30.0 - version: 1.30.1(@opentelemetry/api@1.9.1) + specifier: ^2.10.0 + version: 2.11.0(@opentelemetry/api@1.9.1) '@types/axios': specifier: ^0.14.0 version: 0.14.4 @@ -73,11 +76,11 @@ importers: specifier: ^0.3.4 version: 0.3.4 tmp: - specifier: ^0.2.3 - version: 0.2.5 + specifier: ^0.2.7 + version: 0.2.7 ts-jest: specifier: ^29.1.2 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.7.4))(typescript@5.5.4) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.7.4))(typescript@5.5.4) typedoc: specifier: 0.26.4 version: 0.26.4(typescript@5.5.4) @@ -93,7 +96,7 @@ importers: optionalDependencies: '@huggingface/transformers': specifier: 3.0.2 - version: 3.0.2 + version: 3.0.2(@types/node@22.7.4) openai: specifier: 4.29.2 version: 4.29.2 @@ -283,32 +286,40 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.3': - resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -321,16 +332,24 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.3': @@ -338,6 +357,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-async-generators@7.8.4': resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: @@ -433,14 +457,22 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -504,8 +536,8 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -548,120 +580,165 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@img/sharp-darwin-arm64@0.33.5': - resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.33.5': - resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.0.4': - resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.0.4': - resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.0.4': - resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.0.5': - resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.0.4': - resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.0.4': - resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.0.4': - resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.0.4': - resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.33.5': - resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.33.5': - resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.33.5': - resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.33.5': - resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.33.5': - resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.33.5': - resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.33.5': - resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-ia32@0.33.5': - resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.33.5': - resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1317,26 +1394,26 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} - '@opentelemetry/core@1.30.1': - resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} - engines: {node: '>=14'} + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/resources@1.30.1': - resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/sdk-metrics@1.30.1': - resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} - engines: {node: '>=14'} + '@opentelemetry/resources@2.11.0': + resolution: {integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.28.0': - resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + '@opentelemetry/sdk-metrics@2.11.0': + resolution: {integrity: sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} '@protobufjs/aspromise@1.1.2': @@ -1348,18 +1425,15 @@ packages: '@protobufjs/codegen@2.0.5': resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.1': - resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -1406,6 +1480,7 @@ packages: '@smithy/core@3.24.1': resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025 '@smithy/credential-provider-imds@4.3.1': resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==} @@ -1718,6 +1793,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -1786,8 +1865,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.16.0: - resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -1820,8 +1899,8 @@ packages: base-64@0.1.0: resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==} - baseline-browser-mapping@2.10.29: - resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + baseline-browser-mapping@2.11.19: + resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} engines: {node: '>=6.0.0'} hasBin: true @@ -1831,18 +1910,18 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1872,8 +1951,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1940,13 +2019,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - - color@4.2.3: - resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} - engines: {node: '>=12.5.0'} - colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -2045,8 +2117,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.353: - resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} + electron-to-chromium@1.5.415: + resolution: {integrity: sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -2245,8 +2317,8 @@ packages: form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} formdata-node@4.4.1: @@ -2342,6 +2414,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -2354,6 +2430,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2396,9 +2476,6 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-arrayish@0.3.4: - resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} - is-buffer@1.1.6: resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} @@ -2593,12 +2670,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsesc@3.1.0: @@ -2648,8 +2725,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} @@ -2687,8 +2764,8 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true math-intrinsics@1.1.0: @@ -2793,8 +2870,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -2925,8 +3003,8 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - protobufjs@7.5.7: - resolution: {integrity: sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} proxy-from-env@2.1.0: @@ -3015,9 +3093,19 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.33.5: - resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3047,9 +3135,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-swizzle@0.2.4: - resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} - sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -3120,8 +3205,8 @@ packages: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} - tar@7.5.15: - resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} test-exclude@6.0.0: @@ -3131,8 +3216,8 @@ packages: text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - tmp@0.2.5: - resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} tmpl@1.0.5: @@ -3154,8 +3239,8 @@ packages: peerDependencies: typescript: '>=4.2.0' - ts-jest@29.4.9: - resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==} + ts-jest@29.4.12: + resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -3278,8 +3363,8 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3944,19 +4029,25 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.3': {} - - '@babel/core@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -3974,29 +4065,37 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/generator@7.29.8': dependencies: - '@babel/compat-data': 7.29.3 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -4004,102 +4103,110 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helpers@7.29.2': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + '@babel/parser@7.29.8': dependencies: - '@babel/core': 7.29.0 + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/template@7.28.6': @@ -4108,14 +4215,20 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 - '@babel/traverse@7.29.0': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -4125,6 +4238,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@0.2.3': {} '@biomejs/biome@1.9.4': @@ -4168,7 +4286,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.10.0': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true @@ -4193,7 +4311,7 @@ snapshots: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -4204,12 +4322,14 @@ snapshots: '@huggingface/jinja@0.3.4': optional: true - '@huggingface/transformers@3.0.2': + '@huggingface/transformers@3.0.2(@types/node@22.7.4)': dependencies: '@huggingface/jinja': 0.3.4 onnxruntime-node: 1.19.2 onnxruntime-web: 1.21.0-dev.20241024-d9ca84ef96 - sharp: 0.33.5 + sharp: 0.35.4(@types/node@22.7.4) + transitivePeerDependencies: + - '@types/node' optional: true '@humanwhocodes/config-array@0.13.0': @@ -4224,79 +4344,111 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@img/sharp-darwin-arm64@0.33.5': + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.33.5': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-libvips-darwin-arm64@1.0.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.0.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.0.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.0.5': - optional: true - - '@img/sharp-libvips-linux-s390x@1.0.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.0.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.0.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.0.4': - optional: true - - '@img/sharp-linux-arm64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.0.4 - optional: true - - '@img/sharp-linux-arm@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.0.5 - optional: true - - '@img/sharp-linux-s390x@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.0.4 - optional: true - - '@img/sharp-linux-x64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.0.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.0.4 - optional: true - - '@img/sharp-wasm32@0.33.5': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.10.0 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-ia32@0.33.5': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-win32-x64@0.33.5': + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': optional: true '@inquirer/ansi@2.0.5': {} @@ -4428,7 +4580,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.2 + js-yaml: 3.15.1 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -4568,7 +4720,7 @@ snapshots: '@jest/transform@29.7.0': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 6.1.1 @@ -4614,22 +4766,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/cli@3.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.7.4)': + '@napi-rs/cli@3.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@types/node@22.7.4)': dependencies: '@inquirer/prompts': 8.4.3(@types/node@22.7.4) - '@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@napi-rs/wasm-tools': 1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@napi-rs/wasm-tools': 1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@octokit/rest': 22.0.1 clipanion: 4.0.0-rc.4(typanion@3.14.0) colorette: 2.0.20 emnapi: 1.10.0 es-toolkit: 1.46.1 - js-yaml: 4.1.1 + js-yaml: 4.3.1 obug: 2.1.1 semver: 7.8.0 typanion: 3.14.0 optionalDependencies: - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.11.3 transitivePeerDependencies: - '@emnapi/core' - '@napi-rs/cross-toolchain-arm64-target-aarch64' @@ -4646,10 +4798,10 @@ snapshots: - node-addon-api - supports-color - '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/lzma': 1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@napi-rs/tar': 1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/lzma': 1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@napi-rs/tar': 1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) debug: 4.4.3 transitivePeerDependencies: - '@emnapi/core' @@ -4695,9 +4847,9 @@ snapshots: '@napi-rs/lzma-linux-x64-musl@1.4.5': optional: true - '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -4712,7 +4864,7 @@ snapshots: '@napi-rs/lzma-win32-x64-msvc@1.4.5': optional: true - '@napi-rs/lzma@1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/lzma@1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': optionalDependencies: '@napi-rs/lzma-android-arm-eabi': 1.4.5 '@napi-rs/lzma-android-arm64': 1.4.5 @@ -4727,7 +4879,7 @@ snapshots: '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 '@napi-rs/lzma-linux-x64-gnu': 1.4.5 '@napi-rs/lzma-linux-x64-musl': 1.4.5 - '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 '@napi-rs/lzma-win32-x64-msvc': 1.4.5 @@ -4771,9 +4923,9 @@ snapshots: '@napi-rs/tar-linux-x64-musl@1.1.0': optional: true - '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -4788,7 +4940,7 @@ snapshots: '@napi-rs/tar-win32-x64-msvc@1.1.0': optional: true - '@napi-rs/tar@1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/tar@1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': optionalDependencies: '@napi-rs/tar-android-arm-eabi': 1.1.0 '@napi-rs/tar-android-arm64': 1.1.0 @@ -4802,7 +4954,7 @@ snapshots: '@napi-rs/tar-linux-s390x-gnu': 1.1.0 '@napi-rs/tar-linux-x64-gnu': 1.1.0 '@napi-rs/tar-linux-x64-musl': 1.1.0 - '@napi-rs/tar-wasm32-wasi': 1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/tar-wasm32-wasi': 1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@napi-rs/tar-win32-arm64-msvc': 1.1.0 '@napi-rs/tar-win32-ia32-msvc': 1.1.0 '@napi-rs/tar-win32-x64-msvc': 1.1.0 @@ -4810,10 +4962,10 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.2 optional: true @@ -4844,9 +4996,9 @@ snapshots: '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': optional: true - '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -4861,7 +5013,7 @@ snapshots: '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': optional: true - '@napi-rs/wasm-tools@1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-tools@1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': optionalDependencies: '@napi-rs/wasm-tools-android-arm-eabi': 1.0.1 '@napi-rs/wasm-tools-android-arm64': 1.0.1 @@ -4872,7 +5024,7 @@ snapshots: '@napi-rs/wasm-tools-linux-arm64-musl': 1.0.1 '@napi-rs/wasm-tools-linux-x64-gnu': 1.0.1 '@napi-rs/wasm-tools-linux-x64-musl': 1.0.1 - '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@napi-rs/wasm-tools-win32-arm64-msvc': 1.0.1 '@napi-rs/wasm-tools-win32-ia32-msvc': 1.0.1 '@napi-rs/wasm-tools-win32-x64-msvc': 1.0.1 @@ -4959,24 +5111,24 @@ snapshots: '@opentelemetry/api@1.9.1': {} - '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions@1.28.0': {} + '@opentelemetry/semantic-conventions@1.43.0': {} '@protobufjs/aspromise@1.1.2': optional: true @@ -4987,21 +5139,17 @@ snapshots: '@protobufjs/codegen@2.0.5': optional: true - '@protobufjs/eventemitter@1.1.0': + '@protobufjs/eventemitter@1.1.1': optional: true - '@protobufjs/fetch@1.1.0': + '@protobufjs/fetch@1.1.1': dependencies: '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.1 optional: true '@protobufjs/float@1.0.2': optional: true - '@protobufjs/inquire@1.1.1': - optional: true - '@protobufjs/path@1.1.2': optional: true @@ -5281,9 +5429,10 @@ snapshots: '@types/axios@0.14.4': dependencies: - axios: 1.16.0 + axios: 1.20.0 transitivePeerDependencies: - debug + - supports-color '@types/babel__core@7.20.5': dependencies: @@ -5340,7 +5489,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: '@types/node': 22.7.4 - form-data: 4.0.5 + form-data: 4.0.6 optional: true '@types/node@18.19.130': @@ -5426,7 +5575,7 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.9 - semver: 7.8.0 + semver: 7.8.5 ts-api-utils: 1.4.3(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 @@ -5462,6 +5611,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -5565,21 +5720,23 @@ snapshots: asynckit@0.4.0: {} - axios@1.16.0: + axios@1.20.0: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color - babel-jest@29.7.0(@babel/core@7.29.0): + babel-jest@29.7.0(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@jest/transform': 29.7.0 '@types/babel__core': 7.20.5 babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.0) + babel-preset-jest: 29.6.3(@babel/core@7.29.7) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -5603,48 +5760,48 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) - babel-preset-jest@29.6.3(@babel/core@7.29.0): + babel-preset-jest@29.6.3(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) balanced-match@1.0.2: {} base-64@0.1.0: optional: true - baseline-browser-mapping@2.10.29: {} + baseline-browser-mapping@2.11.19: {} before-after-hook@4.0.0: {} bowser@2.14.1: {} - brace-expansion@1.1.14: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.0: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 @@ -5652,13 +5809,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.10.29 - caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.353 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.19 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.415 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) bs-logger@0.2.6: dependencies: @@ -5681,7 +5838,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001810: {} ccount@2.0.1: {} @@ -5734,18 +5891,6 @@ snapshots: color-name@1.1.4: {} - color-string@1.9.1: - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.4 - optional: true - - color@4.2.3: - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 - optional: true - colorette@2.0.20: {} combined-stream@1.0.8: @@ -5841,7 +5986,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.353: {} + electron-to-chromium@1.5.415: {} emittery@0.13.1: {} @@ -5870,7 +6015,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-toolkit@1.46.1: {} @@ -5918,7 +6063,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.1 + js-yaml: 4.3.1 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -6063,12 +6208,12 @@ snapshots: form-data-encoder@1.7.2: optional: true - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 formdata-node@4.4.1: @@ -6098,7 +6243,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-package-type@0.1.0: {} @@ -6170,6 +6315,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -6192,6 +6341,13 @@ snapshots: html-void-elements@3.0.0: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} humanize-ms@1.2.1: @@ -6228,9 +6384,6 @@ snapshots: is-arrayish@0.2.1: {} - is-arrayish@0.3.4: - optional: true - is-buffer@1.1.6: optional: true @@ -6260,7 +6413,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/parser': 7.29.3 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 @@ -6270,11 +6423,11 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/parser': 7.29.3 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 - semver: 7.8.0 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -6350,10 +6503,10 @@ snapshots: jest-config@29.7.0(@types/node@22.7.4): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) + babel-jest: 29.7.0(@babel/core@7.29.7) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -6534,15 +6687,15 @@ snapshots: jest-snapshot@29.7.0: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) '@babel/types': 7.29.0 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) chalk: 4.1.2 expect: 29.7.0 graceful-fs: 4.2.11 @@ -6553,7 +6706,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.8.0 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -6607,12 +6760,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -6647,7 +6800,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkify-it@5.0.0: + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -6676,7 +6829,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.0 + semver: 7.8.5 make-error@1.3.6: {} @@ -6684,11 +6837,11 @@ snapshots: dependencies: tmpl: 1.0.5 - markdown-it@14.1.1: + markdown-it@14.3.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.0 + linkify-it: 5.0.2 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -6752,11 +6905,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 1.1.18 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.4 minimist@1.2.8: {} @@ -6790,7 +6943,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.38: {} + node-releases@2.0.53: {} normalize-path@3.0.0: {} @@ -6825,7 +6978,7 @@ snapshots: onnxruntime-node@1.19.2: dependencies: onnxruntime-common: 1.19.2 - tar: 7.5.15 + tar: 7.5.22 optional: true onnxruntime-web@1.21.0-dev.20241024-d9ca84ef96: @@ -6835,7 +6988,7 @@ snapshots: long: 5.3.2 onnxruntime-common: 1.20.0-dev.20241016-2b8fc5529b platform: 1.3.6 - protobufjs: 7.5.7 + protobufjs: 7.6.5 optional: true openai@4.29.2: @@ -6931,15 +7084,14 @@ snapshots: property-information@7.1.0: {} - protobufjs@7.5.7: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.1 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -7011,31 +7163,40 @@ snapshots: semver@7.8.0: {} - sharp@0.33.5: + semver@7.8.5: {} + + sharp@0.35.4(@types/node@22.7.4): dependencies: - color: 4.2.3 + '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.0 + semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.33.5 - '@img/sharp-darwin-x64': 0.33.5 - '@img/sharp-libvips-darwin-arm64': 1.0.4 - '@img/sharp-libvips-darwin-x64': 1.0.4 - '@img/sharp-libvips-linux-arm': 1.0.5 - '@img/sharp-libvips-linux-arm64': 1.0.4 - '@img/sharp-libvips-linux-s390x': 1.0.4 - '@img/sharp-libvips-linux-x64': 1.0.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 - '@img/sharp-libvips-linuxmusl-x64': 1.0.4 - '@img/sharp-linux-arm': 0.33.5 - '@img/sharp-linux-arm64': 0.33.5 - '@img/sharp-linux-s390x': 0.33.5 - '@img/sharp-linux-x64': 0.33.5 - '@img/sharp-linuxmusl-arm64': 0.33.5 - '@img/sharp-linuxmusl-x64': 0.33.5 - '@img/sharp-wasm32': 0.33.5 - '@img/sharp-win32-ia32': 0.33.5 - '@img/sharp-win32-x64': 0.33.5 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 22.7.4 optional: true shebang-command@2.0.0: @@ -7070,11 +7231,6 @@ snapshots: signal-exit@4.1.0: {} - simple-swizzle@0.2.4: - dependencies: - is-arrayish: 0.3.4 - optional: true - sisteransi@1.0.5: {} slash@3.0.0: {} @@ -7137,7 +7293,7 @@ snapshots: array-back: 6.2.3 wordwrapjs: 5.1.1 - tar@7.5.15: + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -7154,7 +7310,7 @@ snapshots: text-table@0.2.0: {} - tmp@0.2.5: {} + tmp@0.2.7: {} tmpl@1.0.5: {} @@ -7171,7 +7327,7 @@ snapshots: dependencies: typescript: 5.5.4 - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.7.4))(typescript@5.5.4): + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.7.4))(typescript@5.5.4): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -7180,15 +7336,15 @@ snapshots: json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.8.0 + semver: 7.8.5 type-fest: 4.41.0 typescript: 5.5.4 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) + babel-jest: 29.7.0(@babel/core@7.29.7) jest-util: 29.7.0 tslib@2.8.1: {} @@ -7214,7 +7370,7 @@ snapshots: typedoc@0.26.4(typescript@5.5.4): dependencies: lunr: 2.3.9 - markdown-it: 14.1.1 + markdown-it: 14.3.0 minimatch: 9.0.9 shiki: 1.29.2 typescript: 5.5.4 @@ -7274,9 +7430,9 @@ snapshots: universal-user-agent@7.0.3: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 diff --git a/nodejs/pnpm-workspace.yaml b/nodejs/pnpm-workspace.yaml index 06024e2f4..b6be57977 100644 --- a/nodejs/pnpm-workspace.yaml +++ b/nodejs/pnpm-workspace.yaml @@ -16,3 +16,41 @@ allowBuilds: onnxruntime-node: true protobufjs: true sharp: true + +minimumReleaseAgeExclude: + - protobufjs@7.5.8 + - tmp@0.2.6 + - form-data@4.0.6 + - tar@7.5.16 + - markdown-it@14.1.2 + - linkify-it@5.0.1 + - js-yaml@3.15.0 + - js-yaml@4.1.2 + - protobufjs@7.6.1 + - protobufjs@7.6.3 + - '@babel/core@7.29.1' + - axios@1.18.0 + - brace-expansion@2.1.2 + - brace-expansion@1.1.16 + - js-yaml@4.3.0 + - tar@7.5.18 + - tar@7.5.19 + - tar@7.5.17 + - protobufjs@7.6.5 + - linkify-it@5.0.2 + - sharp@0.35.0 + - brace-expansion@1.1.17 + - brace-expansion@2.1.3 + - brace-expansion@2.1.4 + - brace-expansion@1.1.18 + - js-yaml@3.15.1 + - js-yaml@4.3.1 + - tar@7.5.21 + - '@opentelemetry/core@2.8.0' + +# @huggingface/transformers pins sharp ^0.33.5 and no released version has moved +# past ^0.34.5, all of which inherit the libvips CVEs in GHSA-f88m-g3jw-g9cj. +# Force the patched line. sharp is only reached by transformers' image pipeline, +# which LanceDB's text embedding function never uses. +overrides: + sharp: ^0.35.4 diff --git a/nodejs/src/blob.rs b/nodejs/src/blob.rs new file mode 100644 index 000000000..230a11bae --- /dev/null +++ b/nodejs/src/blob.rs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::ops::Range; +use std::sync::Arc; + +use arrow_array::{Array, LargeBinaryArray}; +use lancedb::blob::BlobFile as LanceBlobFile; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +use crate::error::convert_error; + +#[napi] +pub struct BlobFile { + inner: Arc, +} + +impl BlobFile { + pub(crate) fn new(inner: LanceBlobFile) -> Self { + Self { + inner: Arc::new(inner), + } + } +} + +#[napi] +impl BlobFile { + #[napi] + pub fn size(&self) -> BigInt { + BigInt::from(self.inner.size()) + } + + #[napi] + pub async fn read(&self) -> napi::Result { + let bytes = self.inner.read().await.map_err(|err| convert_error(&err))?; + Ok(Buffer::from(bytes.as_ref())) + } + + #[napi] + pub async fn read_range(&self, start: BigInt, end: BigInt) -> napi::Result { + let range = bigint_range(start, end)?; + let bytes = self + .inner + .read_range(range) + .await + .map_err(|err| convert_error(&err))?; + Ok(Buffer::from(bytes.as_ref())) + } +} + +fn bigint_range(start: BigInt, end: BigInt) -> napi::Result> { + let start = parse_u64(start, "start")?; + let end = parse_u64(end, "end")?; + if start > end { + return Err(napi::Error::from_reason(format!( + "invalid blob range: start ({start}) > end ({end})" + ))); + } + Ok(start..end) +} + +fn parse_u64(value: BigInt, name: &str) -> napi::Result { + let (negative, value, lossless) = value.get_u64(); + if negative { + return Err(napi::Error::from_reason(format!( + "{name} cannot be negative" + ))); + } + if !lossless { + return Err(napi::Error::from_reason(format!( + "{name} is too large to fit in u64" + ))); + } + Ok(value) +} + +pub fn parse_row_ids(row_ids: Vec) -> napi::Result> { + row_ids + .into_iter() + .map(|id| parse_u64(id, "row id")) + .collect() +} + +pub fn copy_blob_buffers(array: LargeBinaryArray) -> Vec> { + (0..array.len()) + .map(|i| { + if array.is_null(i) { + None + } else { + Some(Buffer::from(array.value(i).to_vec())) + } + }) + .collect() +} diff --git a/nodejs/src/catalog.rs b/nodejs/src/catalog.rs new file mode 100644 index 000000000..c2288bc4a --- /dev/null +++ b/nodejs/src/catalog.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::sync::Arc; +use std::time::Duration; + +use lancedb::catalog::{ + CatalogConnection, CreateDatabaseRequest, DropDatabaseRequest, ListDatabasesRequest, +}; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +use crate::connection::Connection; +use crate::error::NapiErrorExt; +use crate::header::JsHeaderProvider; +use crate::remote::{ClientConfig, OAuthConfig}; + +#[napi(object)] +pub struct CatalogOptions { + pub api_key: Option, + pub client_config: Option, + /// SQL service endpoint inherited by database connections. + pub sql_host_override: Option, + pub read_consistency_interval: Option, + pub oauth_config: Option, +} + +#[napi(object)] +pub struct ListDatabasesResponse { + pub databases: Vec, + pub page_token: Option, +} + +#[napi] +pub struct Catalog { + inner: CatalogConnection, +} + +#[napi] +impl Catalog { + #[napi(factory)] + pub async fn new( + endpoint: String, + options: CatalogOptions, + header_provider: Option<&JsHeaderProvider>, + ) -> Result { + let mut builder = lancedb::connect_catalog(endpoint); + if let Some(key) = options.api_key { + builder = builder.api_key(key); + } + let mut config: lancedb::remote::ClientConfig = + options.client_config.unwrap_or_default().into(); + if let Some(provider) = header_provider { + config.header_provider = Some(Arc::new(provider.clone())); + } + builder = builder.client_config(config); + if let Some(endpoint) = options.sql_host_override { + builder = builder.sql_host_override(endpoint); + } + if let Some(interval) = options.read_consistency_interval { + let interval = Duration::try_from_secs_f64(interval).map_err(|err| { + Error::from_reason(format!("Invalid read consistency interval: {err}")) + })?; + builder = builder.read_consistency_interval(interval); + } + if let Some(oauth) = options.oauth_config { + builder = builder.oauth_config(oauth.try_into().default_error()?); + } + Ok(Self { + inner: builder.execute().await.default_error()?, + }) + } + + #[napi(getter)] + pub fn uri(&self) -> String { + self.inner.uri().to_string() + } + + #[napi] + pub async fn create_database( + &self, + name: String, + exist_ok: Option, + ) -> Result { + self.inner + .create_database(CreateDatabaseRequest::new(name).exist_ok(exist_ok.unwrap_or(false))) + .await + .map(Connection::inner_new) + .default_error() + } + #[napi] + pub async fn connect_database(&self, name: String) -> Result { + self.inner + .connect_database(name) + .await + .map(Connection::inner_new) + .default_error() + } + #[napi] + pub async fn drop_database(&self, name: String, ignore_missing: Option) -> Result<()> { + self.inner + .drop_database( + DropDatabaseRequest::new(name).ignore_missing(ignore_missing.unwrap_or(false)), + ) + .await + .default_error() + } + #[napi] + pub async fn list_databases( + &self, + limit: Option, + page_token: Option, + ) -> Result { + let mut request = ListDatabasesRequest::default(); + request.limit = limit; + request.page_token = page_token; + let response = self.inner.list_databases(request).await.default_error()?; + Ok(ListDatabasesResponse { + databases: response.databases, + page_token: response.page_token, + }) + } +} diff --git a/nodejs/src/connection.rs b/nodejs/src/connection.rs index 5cf676256..0c07ffd50 100644 --- a/nodejs/src/connection.rs +++ b/nodejs/src/connection.rs @@ -308,6 +308,7 @@ impl Connection { projections: Option>>, filter: Option, limit: Option, + with_no_data: bool, ) -> napi::Result { let mut builder = self.get_inner()?.create_materialized_view(name, source); if let Some(projections) = projections { @@ -328,6 +329,7 @@ impl Connection { .map_err(|_| napi::Error::from_reason("limit must be a non-negative integer"))?; builder = builder.limit(limit); } + builder = builder.with_no_data(with_no_data); let view = builder.execute().await.default_error()?; Ok(Table::new(view.table().clone())) } @@ -349,7 +351,37 @@ impl Connection { .list_materialized_views() .await .default_error()?; - Ok(views.into_iter().map(|v| v.name).collect()) + Ok(views) + } + + /// Drop a materialized view. + #[napi(catch_unwind)] + pub async fn drop_materialized_view( + &self, + name: String, + namespace_path: Option>, + ) -> napi::Result<()> { + let ns = namespace_path.unwrap_or_default(); + self.get_inner()? + .drop_materialized_view(&name, &ns) + .await + .default_error() + } + + /// Start dropping a materialized view and return its cleanup job. + #[napi(catch_unwind)] + pub async fn drop_materialized_view_async( + &self, + name: String, + namespace_path: Option>, + ) -> napi::Result { + let ns = namespace_path.unwrap_or_default(); + let job = self + .get_inner()? + .drop_materialized_view_async(&name, &ns) + .await + .default_error()?; + Ok(crate::job::Job::new(job)) } #[napi(catch_unwind)] @@ -442,13 +474,15 @@ impl Connection { self.get_inner()?.drop_all_tables(&ns).await.default_error() } - /// A `Job` handle for a server-side job by id. + /// Open a server-side job by id, returning a handle with its record + /// already populated. Rejects when the server has no such job. /// - /// The handle is constructed without a server round trip; an unknown id - /// surfaces when the handle is used. - #[napi] - pub fn job(&self, job_id: String) -> napi::Result { - let job = self.get_inner()?.job(job_id).default_error()?; + /// The returned handle answers for its own state, specification, result, + /// failure and event history, so there is no separate connection-level + /// call for any of them. + #[napi(catch_unwind)] + pub async fn open_job(&self, job_id: String) -> napi::Result { + let job = self.get_inner()?.open_job(&job_id).await.default_error()?; Ok(crate::job::Job::new(job)) } @@ -459,17 +493,6 @@ impl Connection { Ok(jobs.into_iter().map(Into::into).collect()) } - /// Describe a single server-side job by id. `null` when the server has - /// no such job. - #[napi(catch_unwind)] - pub async fn get_job( - &self, - job_id: String, - ) -> napi::Result> { - let description = self.get_inner()?.get_job(&job_id).await.default_error()?; - Ok(description.map(Into::into)) - } - /// Request cancellation of a server-side job by id. Returns true if the /// server accepted the cancellation, false if no such job exists. #[napi(catch_unwind)] @@ -477,34 +500,6 @@ impl Connection { self.get_inner()?.cancel_job(&job_id).await.default_error() } - /// The lifecycle event history of a server-side job (all jobs when - /// `job_id` is null), as an Arrow IPC stream buffer. Empty when there is - /// no history. - #[napi(catch_unwind)] - pub async fn job_history(&self, job_id: Option) -> napi::Result { - let batches = self - .get_inner()? - .job_history(job_id.as_deref()) - .await - .default_error()?; - let Some(first) = batches.first() else { - return Ok(Buffer::from(Vec::::new())); - }; - let mut out = Vec::new(); - let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut out, &first.schema()) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - for batch in &batches { - writer - .write(batch) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - } - writer - .finish() - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - drop(writer); - Ok(Buffer::from(out)) - } - #[napi(catch_unwind)] /// Describe a namespace and return its properties. pub async fn describe_namespace( diff --git a/nodejs/src/job.rs b/nodejs/src/job.rs index 14013fd27..214e687b4 100644 --- a/nodejs/src/job.rs +++ b/nodejs/src/job.rs @@ -3,6 +3,9 @@ use std::sync::Arc; +use arrow_array::RecordBatch; +use lancedb::job::JobEventsRequest; +use napi::bindgen_prelude::Buffer; use napi_derive::napi; use crate::error::NapiErrorExt; @@ -55,12 +58,98 @@ impl Job { pub async fn cancel(&self) -> napi::Result<()> { self.inner.cancel().await.default_error() } + + /// Ask the backend for this job's current state, and for a server-side job + /// its full record, then cache it for the getters below. + /// + /// They are all null until this runs, because submitting an operation + /// returns only a job id. {@link Job.status} fetches the whole record too; + /// {@link Job.wait} records only the terminal state it establishes. + #[napi(catch_unwind)] + pub async fn refresh(&self) -> napi::Result<()> { + self.inner.refresh().await.default_error() + } + + /// The last observed lifecycle state, without contacting the backend. + #[napi(getter)] + pub fn state(&self) -> Option { + self.inner.state() + } + + /// The job's type, as the server names it. Null for an in-process job, + /// which has no server-side record. + #[napi(getter)] + pub fn job_type(&self) -> Option { + self.inner.job_type() + } + + /// When the job was created, in milliseconds since the epoch. + #[napi(getter)] + pub fn creation_ms(&self) -> Option { + self.inner.creation_ms() + } + + /// The job-type-specific specification as a JSON string, when present. + #[napi(getter)] + pub fn spec_json(&self) -> Option { + self.inner.spec().map(|spec| spec.to_string()) + } + + /// The job-type-specific terminal result as a JSON string. Null until the + /// job succeeds, so a job that never terminates reports its progress + /// through {@link Job.events} instead. + #[napi(getter)] + pub fn result_json(&self) -> Option { + self.inner.result().map(|result| result.to_string()) + } + + /// Why the job failed, when it failed and the server reports a reason. + #[napi(getter)] + pub fn failure(&self) -> Option { + self.inner.failure().map(|failure| JobFailureInfo { + phase: failure.phase, + message: failure.message, + retryable: failure.retryable, + }) + } + + /// This job's recorded lifecycle events, as an Arrow IPC stream buffer. + /// The TypeScript wrapper turns it into an Arrow table. + #[napi(catch_unwind)] + pub async fn events(&self, limit: Option, filter: Option) -> napi::Result { + let batches = self + .inner + .events(JobEventsRequest { limit, filter }) + .await + .default_error()?; + batches_to_ipc_buffer(&batches) + } +} + +/// Serialise Arrow batches as a single IPC stream for the TypeScript layer. +fn batches_to_ipc_buffer(batches: &[RecordBatch]) -> napi::Result { + let Some(first) = batches.first() else { + return Ok(Buffer::from(Vec::::new())); + }; + let mut out = Vec::new(); + let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut out, &first.schema()) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + for batch in batches { + writer + .write(batch) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + } + writer + .finish() + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + drop(writer); + Ok(Buffer::from(out)) } /// A row from `Connection.listJobs`: one server-side job. #[napi(object)] pub struct JobInfo { - /// The job id -- what `Connection.getJob` and `Connection.cancelJob` + /// The job id -- what `Connection.openJob` and `Connection.cancelJob` /// accept. pub job_id: String, /// The table the job runs against, without URI or namespace. @@ -91,36 +180,3 @@ pub struct JobFailureInfo { pub message: Option, pub retryable: Option, } - -/// A described job from `Connection.getJob`. -#[napi(object)] -pub struct JobDescription { - pub job_id: String, - pub job_type: String, - /// Lifecycle state: "running", "finished", "failed", or "cancelled". - pub state: String, - /// When the job was created, in milliseconds since the epoch. - pub creation_ms: i64, - /// The job-type-specific specification as a JSON string, when present. - pub spec_json: Option, - /// Why the job failed, when the job is failed and the server reports a - /// reason. - pub failure: Option, -} - -impl From for JobDescription { - fn from(description: lancedb::database::JobDescription) -> Self { - Self { - job_id: description.job_id, - job_type: description.job_type, - state: description.state, - creation_ms: description.creation_ms, - spec_json: (!description.spec.is_null()).then(|| description.spec.to_string()), - failure: description.failure.map(|failure| JobFailureInfo { - phase: failure.phase, - message: failure.message, - retryable: failure.retryable, - }), - } - } -} diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs index 1110f6203..1bb5f761d 100644 --- a/nodejs/src/lib.rs +++ b/nodejs/src/lib.rs @@ -10,6 +10,8 @@ use std::collections::HashMap; use env_logger::Env; use napi_derive::*; +mod blob; +mod catalog; mod connection; mod error; mod header; diff --git a/nodejs/src/query.rs b/nodejs/src/query.rs index 3828023a9..f9fe91d35 100644 --- a/nodejs/src/query.rs +++ b/nodejs/src/query.rs @@ -664,7 +664,11 @@ impl JsFullTextQuery { } fn parse_fts_query(query: Object) -> napi::Result { - if let Ok(Some(query)) = query.get::<&JsFullTextQuery>("query") { + // `&JsFullTextQuery` recovers a native class reference through napi's borrow-tracked + // path, which is only usable from generated `#[napi]` argument conversion. This is a + // manual lookup on a nested `Object` property instead, so use `ClassInstance`, which + // unwraps the class without requiring a borrow scope. + if let Ok(Some(query)) = query.get::>("query") { Ok(FullTextSearchQuery::new_query(query.inner.clone())) } else if let Ok(Some(query_text)) = query.get::("query") { let mut query_text = query_text; diff --git a/nodejs/src/remote.rs b/nodejs/src/remote.rs index 4bdb5685e..c2d671735 100644 --- a/nodejs/src/remote.rs +++ b/nodejs/src/remote.rs @@ -6,6 +6,8 @@ use std::collections::HashMap; use lancedb::error::Error; use napi_derive::*; +use crate::error::NapiErrorExt; + /// Timeout configuration for remote HTTP client. #[napi(object)] #[derive(Debug)] @@ -91,6 +93,9 @@ pub struct ClientConfig { pub retry_config: Option, pub timeout_config: Option, pub extra_headers: Option>, + /// The delimiter joining a namespace path and a name into one object + /// identifier. `"$"` is the only supported value, and leaving this unset is + /// how to get it; anything else is rejected when the connection is created. pub id_delimiter: Option, pub tls_config: Option, /// User identifier for tracking purposes. @@ -141,6 +146,34 @@ impl From for lancedb::remote::TlsConfig { } } +/// Options for the persistent OAuth token cache. +/// +/// The cache is opt-in: it is only used when set as `tokenCache` on +/// `OAuthConfig`. Only refresh tokens are persisted, in a private directory +/// with owner-only permissions, so short-lived processes can reuse an +/// authenticated session instead of re-prompting on every start. +#[napi(object)] +#[derive(Clone, Debug, Default)] +pub struct TokenCacheOptions { + /// Directory that holds cached credentials. Defaults to + /// `$XDG_CACHE_HOME/lancedb/oauth`, `$HOME/.cache/lancedb/oauth` on Unix, + /// or `%LOCALAPPDATA%\lancedb\oauth` on Windows. The directory is created + /// with owner-only permissions (`0700`) when missing. + pub cache_dir: Option, + /// How long to wait for the cross-process refresh lock before failing, + /// in seconds (default: 30). + pub lock_timeout_secs: Option, +} + +impl From for lancedb::remote::TokenCacheOptions { + fn from(options: TokenCacheOptions) -> Self { + Self { + cache_dir: options.cache_dir.map(std::path::PathBuf::from), + lock_timeout_secs: options.lock_timeout_secs.map(|secs| secs as u64), + } + } +} + /// OAuth configuration for LanceDB authentication. /// /// This is the generated napi-rs binding shape. TypeScript users should prefer @@ -158,16 +191,35 @@ pub struct OAuthConfig { /// OAuth scopes to request. For Azure managed identity, exactly one scope /// or resource is required. For example: `["api://{app_id}/.default"]` pub scopes: Vec, - /// Authentication flow: "client_credentials" or "azure_managed_identity" + /// Optional resource indicator for authorization and token requests. + pub resource: Option, + /// Optional provider-specific audience for authorization and token requests. + pub audience: Option, + /// Authentication flow: "client_credentials", "authorization_code", + /// "device_code", or "azure_managed_identity" pub flow: Option, /// Client secret (required for client_credentials). pub client_secret: Option, + /// How the client authenticates to the token endpoint: "none", + /// "client_secret_basic", or "client_secret_post". Defaults to + /// "client_secret_basic" when a client secret is set, and "none" for + /// public clients. + pub client_auth_method: Option, + /// Loopback redirect URI for authorization_code. + pub redirect_uri: Option, + /// Port for the authorization_code loopback callback server. + pub callback_port: Option, + /// Whether authorization_code uses S256 PKCE (default: true). + pub use_pkce: Option, /// Client ID for user-assigned managed identity (azure_managed_identity). pub managed_identity_client_id: Option, /// Seconds before expiry to trigger proactive refresh (default: 300). /// Keep this well below the token TTL; if it is greater than or equal to /// the TTL, each request refreshes the token. pub refresh_buffer_secs: Option, + /// Opt in to the persistent token cache so short-lived processes reuse + /// one session. Only refresh tokens are persisted. + pub token_cache: Option, } impl std::fmt::Debug for OAuthConfig { @@ -176,16 +228,23 @@ impl std::fmt::Debug for OAuthConfig { .field("issuer_url", &self.issuer_url) .field("client_id", &self.client_id) .field("scopes", &self.scopes) + .field("resource", &self.resource) + .field("audience", &self.audience) .field("flow", &self.flow) .field( "client_secret", &self.client_secret.as_deref().map(|_| ""), ) + .field("client_auth_method", &self.client_auth_method) + .field("redirect_uri", &self.redirect_uri) + .field("callback_port", &self.callback_port) + .field("use_pkce", &self.use_pkce) .field( "managed_identity_client_id", &self.managed_identity_client_id, ) .field("refresh_buffer_secs", &self.refresh_buffer_secs) + .field("token_cache", &self.token_cache) .finish() } } @@ -194,10 +253,22 @@ impl TryFrom for lancedb::remote::oauth::OAuthConfig { type Error = Error; fn try_from(config: OAuthConfig) -> Result { - use lancedb::remote::oauth::OAuthFlow; + use lancedb::remote::oauth::{AuthorizationCodeOptions, OAuthFlow}; let flow = match config.flow.as_deref().unwrap_or("client_credentials") { "client_credentials" => OAuthFlow::ClientCredentials, + "authorization_code" => { + let mut options = + AuthorizationCodeOptions::new().use_pkce(config.use_pkce.unwrap_or(true)); + if let Some(redirect_uri) = config.redirect_uri { + options = options.redirect_uri(redirect_uri); + } + if let Some(callback_port) = config.callback_port { + options = options.callback_port(callback_port); + } + OAuthFlow::AuthorizationCode(options) + } + "device_code" => OAuthFlow::DeviceCode, "azure_managed_identity" => OAuthFlow::AzureManagedIdentity { client_id: config.managed_identity_client_id, }, @@ -208,17 +279,147 @@ impl TryFrom for lancedb::remote::oauth::OAuthConfig { } }; + let client_auth_method = match config.client_auth_method.as_deref() { + Some("none") => Some(lancedb::remote::oauth::ClientAuthMethod::None), + Some("client_secret_basic") => { + Some(lancedb::remote::oauth::ClientAuthMethod::ClientSecretBasic) + } + Some("client_secret_post") => { + Some(lancedb::remote::oauth::ClientAuthMethod::ClientSecretPost) + } + None => None, + Some(other) => { + return Err(Error::InvalidInput { + message: format!("Unknown OAuth client auth method: {other}"), + }); + } + }; + Ok(Self { issuer_url: config.issuer_url, client_id: config.client_id, client_secret: config.client_secret, + client_auth_method, scopes: config.scopes, + resource: config.resource, + audience: config.audience, flow, refresh_buffer_secs: config.refresh_buffer_secs.map(|v| v as u64), + token_cache: config.token_cache.map(Into::into), }) } } +/// Safe, non-secret view of a cached OAuth session, returned by +/// `OAuthSession.status()` and `OAuthSession.login()`. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct SessionStatus { + /// Whether a cached session exists that can obtain tokens without + /// interactive authentication. + pub refreshable: bool, + /// Canonical issuer URL of the cached session. + pub issuer_url: String, + /// Client ID of the cached session. + pub client_id: String, + /// Canonical (sorted, de-duplicated) scopes of the cached session. + pub scopes: Vec, + /// Optional resource indicator for authorization and token requests. + pub resource: Option, + /// Optional provider-specific audience for authorization and token requests. + pub audience: Option, + /// Flow that produced the cached session. + pub flow: String, + /// When the cached session was obtained, as Unix seconds. + pub obtained_at: Option, +} + +/// Result of `OAuthSession.logout()`. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct SessionLogout { + /// Whether a cached credential was removed. `false` means no matching + /// session was cached; logout is idempotent. + pub removed: bool, +} + +/// Explicit OAuth session lifecycle for the persistent token cache: eager +/// `login`, non-secret `status`, and local `logout`. +/// +/// A session is built from the same `OAuthConfig` used to connect (including +/// its `tokenCache` options). A connection created with the same +/// configuration shares the cache, so logging in here prepares tokens for +/// later processes without any database request. +#[napi] +pub struct OAuthSession { + inner: lancedb::remote::OAuthSession, +} + +#[napi] +impl OAuthSession { + /// Create a session manager for the given OAuth configuration. + /// + /// The configuration must enable `tokenCache` options and use a flow that + /// supports persistent sessions (authorization code or device code). + #[napi(constructor)] + pub fn new(config: OAuthConfig) -> napi::Result { + let config: lancedb::remote::oauth::OAuthConfig = config.try_into().default_error()?; + let inner = lancedb::remote::OAuthSession::new(config).default_error()?; + Ok(Self { inner }) + } + + /// Eagerly run the configured authentication flow and store the session. + /// + /// A successful login always replaces any prior cached session for this + /// identity; if the provider does not issue a refresh token (for example + /// without `offline_access`), the previous record is removed and the + /// status reports `refreshable == false`. + #[napi(catch_unwind)] + pub async fn login(&self) -> napi::Result { + let status = self.inner.login().await.default_error()?; + Ok(SessionStatus::from(status)) + } + + /// Report whether a matching cached session exists, with safe metadata. + /// + /// This never contacts the identity provider and never exposes token + /// values. + #[napi(catch_unwind)] + pub async fn status(&self) -> napi::Result { + let status = self.inner.status().await.default_error()?; + Ok(SessionStatus::from(status)) + } + + /// Remove the matching local cached credential. + /// + /// This only deletes the local cache entry. It does not revoke the + /// refresh token with the provider and does not sign out of a browser + /// SSO session. Repeated calls succeed; `removed` reports whether a + /// credential existed. + #[napi(catch_unwind)] + pub async fn logout(&self) -> napi::Result { + let logout = self.inner.logout().await.default_error()?; + Ok(SessionLogout { + removed: logout.removed, + }) + } +} + +impl From for SessionStatus { + fn from(status: lancedb::remote::SessionStatus) -> Self { + Self { + refreshable: status.refreshable, + issuer_url: status.issuer_url, + client_id: status.client_id, + scopes: status.scopes, + resource: status.resource, + audience: status.audience, + flow: status.flow, + obtained_at: status.obtained_at.map(|secs| secs as f64), + } + } +} + impl From for lancedb::remote::ClientConfig { fn from(config: ClientConfig) -> Self { Self { @@ -252,8 +453,15 @@ mod tests { scopes: vec!["scope".to_string()], flow: Some("typo".to_string()), client_secret: None, + client_auth_method: None, + redirect_uri: None, + callback_port: None, + use_pkce: None, managed_identity_client_id: None, refresh_buffer_secs: None, + resource: None, + audience: None, + token_cache: None, }; let err = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap_err(); @@ -272,12 +480,136 @@ mod tests { scopes: vec!["scope".to_string()], flow: Some("client_credentials".to_string()), client_secret: Some("super-secret".to_string()), + client_auth_method: None, + redirect_uri: None, + callback_port: None, + use_pkce: None, managed_identity_client_id: None, refresh_buffer_secs: None, + resource: None, + audience: None, + token_cache: None, }; let debug = format!("{config:?}"); assert!(!debug.contains("super-secret")); assert!(debug.contains("client_secret: Some(\"\")")); } + + #[test] + fn test_authorization_code_conversion_preserves_options() { + let config = OAuthConfig { + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + scopes: vec!["openid".to_string()], + flow: Some("authorization_code".to_string()), + client_secret: Some("secret".to_string()), + client_auth_method: None, + redirect_uri: Some("http://127.0.0.1:9000/callback".to_string()), + callback_port: Some(9000), + use_pkce: Some(false), + managed_identity_client_id: None, + refresh_buffer_secs: None, + resource: Some("urn:resource".into()), + audience: Some("audience".into()), + token_cache: None, + }; + + let converted = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap(); + let lancedb::remote::oauth::OAuthFlow::AuthorizationCode(options) = converted.flow else { + panic!("expected authorization code flow"); + }; + assert_eq!( + options.redirect_uri.as_deref(), + Some("http://127.0.0.1:9000/callback") + ); + assert_eq!(options.callback_port, Some(9000)); + assert!(!options.use_pkce); + assert_eq!(converted.resource.as_deref(), Some("urn:resource")); + assert_eq!(converted.audience.as_deref(), Some("audience")); + } + + #[test] + fn test_device_code_conversion() { + let config = OAuthConfig { + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + scopes: vec!["openid".to_string()], + flow: Some("device_code".to_string()), + client_secret: None, + client_auth_method: None, + redirect_uri: None, + callback_port: None, + use_pkce: None, + managed_identity_client_id: None, + refresh_buffer_secs: None, + resource: None, + audience: None, + token_cache: None, + }; + + let converted = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap(); + assert!(matches!( + converted.flow, + lancedb::remote::oauth::OAuthFlow::DeviceCode + )); + } + + #[test] + fn test_client_auth_method_conversion() { + use lancedb::remote::oauth::ClientAuthMethod; + + for (value, expected) in [ + ("none", ClientAuthMethod::None), + ("client_secret_basic", ClientAuthMethod::ClientSecretBasic), + ("client_secret_post", ClientAuthMethod::ClientSecretPost), + ] { + let config = OAuthConfig { + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + scopes: vec!["openid".to_string()], + flow: Some("device_code".to_string()), + client_secret: None, + client_auth_method: Some(value.to_string()), + resource: None, + audience: None, + redirect_uri: None, + callback_port: None, + use_pkce: None, + managed_identity_client_id: None, + refresh_buffer_secs: None, + token_cache: None, + }; + + let converted = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap(); + assert_eq!(converted.client_auth_method, Some(expected)); + } + } + + #[test] + fn test_unknown_client_auth_method_returns_invalid_input() { + let config = OAuthConfig { + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + scopes: vec!["openid".to_string()], + flow: Some("device_code".to_string()), + client_secret: None, + client_auth_method: Some("typo".to_string()), + resource: None, + audience: None, + redirect_uri: None, + callback_port: None, + use_pkce: None, + managed_identity_client_id: None, + refresh_buffer_secs: None, + token_cache: None, + }; + + let err = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message == "Unknown OAuth client auth method: typo" + )); + } } diff --git a/nodejs/src/table.rs b/nodejs/src/table.rs index db74d38fa..344017d08 100644 --- a/nodejs/src/table.rs +++ b/nodejs/src/table.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use lancedb::ipc::{ipc_file_to_batches, ipc_file_to_schema}; use lancedb::table::{ - AddDataMode, ColumnAlteration as LanceColumnAlteration, Duration, + AddDataMode, ColumnAlteration as LanceColumnAlteration, FieldMetadataUpdate as LanceFieldMetadataUpdate, FtsToken as LanceDbFtsToken, NewColumnTransform, OptimizeAction, OptimizeOptions, Ref, Table as LanceDbTable, }; @@ -15,6 +15,7 @@ use napi::bindgen_prelude::*; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi_derive::napi; +use crate::blob::{BlobFile, copy_blob_buffers, parse_row_ids}; use crate::error::NapiErrorExt; use crate::index::Index; use crate::merge::NativeMergeInsertBuilder; @@ -329,6 +330,44 @@ impl Table { )) } + #[napi(catch_unwind)] + pub async fn blob_columns(&self) -> napi::Result> { + self.inner_ref()?.blob_columns().await.default_error() + } + + #[napi(catch_unwind)] + pub async fn fetch_blobs( + &self, + column: String, + row_ids: Vec, + ) -> napi::Result>> { + let row_ids = parse_row_ids(row_ids)?; + let array = self + .inner_ref()? + .fetch_blobs(column.as_str(), &row_ids) + .await + .default_error()?; + Ok(copy_blob_buffers(array)) + } + + #[napi(catch_unwind)] + pub async fn fetch_blob_files( + &self, + column: String, + row_ids: Vec, + ) -> napi::Result>> { + let row_ids = parse_row_ids(row_ids)?; + let files = self + .inner_ref()? + .fetch_blob_files(column.as_str(), &row_ids) + .await + .default_error()?; + Ok(files + .into_iter() + .map(|file| file.map(BlobFile::new)) + .collect()) + } + #[napi(catch_unwind)] pub fn vector_search(&self, vector: Float32Array) -> napi::Result { self.query()?.nearest_to(vector) @@ -408,6 +447,19 @@ impl Table { Ok(result.into()) } + #[napi(catch_unwind)] + pub async fn materialized_view_definition(&self) -> napi::Result { + let inner = self.inner_ref()?.clone(); + let view = lancedb::MaterializedView::from_table(inner) + .await + .default_error()?; + serde_json::to_string(view.definition()).map_err(|err| { + napi::Error::from_reason(format!( + "failed to serialize materialized-view definition: {err}" + )) + }) + } + #[napi(catch_unwind)] pub async fn add_columns_with_schema( &self, @@ -638,22 +690,20 @@ impl Table { #[napi(catch_unwind)] pub async fn optimize( &self, - older_than_ms: Option, + before_timestamp_ms: Option, delete_unverified: Option, ) -> napi::Result { let inner = self.inner_ref()?; - let older_than = if let Some(ms) = older_than_ms { - if ms == i64::MIN { - return Err(napi::Error::from_reason(format!( - "older_than_ms can not be {}", - i32::MIN, - ))); - } - Duration::try_milliseconds(ms) - } else { - None - }; + let before_timestamp = before_timestamp_ms + .map(|ms| { + DateTime::from_timestamp_millis(ms).ok_or_else(|| { + napi::Error::from_reason(format!( + "cleanupOlderThan timestamp is out of range: {ms}" + )) + }) + }) + .transpose()?; let compaction_stats = inner .optimize(OptimizeAction::Compact { @@ -664,16 +714,22 @@ impl Table { .default_error()? .compaction .unwrap(); - let prune_stats = inner - .optimize(OptimizeAction::Prune { - older_than, - delete_unverified, - error_if_tagged_old_versions: None, - }) - .await - .default_error()? - .prune - .unwrap(); + let prune_stats = if let Some(before_timestamp) = before_timestamp { + inner + .optimize_prune_before(before_timestamp, delete_unverified, None) + .await + } else { + inner + .optimize(OptimizeAction::Prune { + older_than: None, + delete_unverified, + error_if_tagged_old_versions: None, + }) + .await + } + .default_error()? + .prune + .unwrap(); inner .optimize(lancedb::table::OptimizeAction::Index( OptimizeOptions::default(), diff --git a/nodejs/typedoc.json b/nodejs/typedoc.json index e46085cda..c601ee61e 100644 --- a/nodejs/typedoc.json +++ b/nodejs/typedoc.json @@ -4,7 +4,8 @@ "lancedb/native.d.ts:VectorQuery", "lancedb/native.d.ts:TakeQuery", "lancedb/native.d.ts:RecordBatchIterator", - "lancedb/native.d.ts:NativeMergeInsertBuilder" + "lancedb/native.d.ts:NativeMergeInsertBuilder", + "lancedb/native.d.ts:TokenCacheOptions" ], "useHTMLEncodedBrackets": true, "useCodeBlocks": true, diff --git a/python/Cargo.toml b/python/Cargo.toml index 0ee561977..705eb4a0a 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb-python" -version = "0.38.0-beta.12" +version = "0.40.0-beta.1" publish = false edition.workspace = true description = "Python bindings for LanceDB" @@ -15,6 +15,7 @@ name = "_lancedb" crate-type = ["cdylib"] [dependencies] +arc-swap = "1.9" arrow = { workspace = true, features = ["pyarrow"] } async-trait.workspace = true bytes.workspace = true @@ -28,7 +29,7 @@ env_logger.workspace = true log.workspace = true # Maturin enables extension-module mode for Python builds. Keeping it out of # Cargo features lets Rust unit tests link against libpython. -pyo3 = { version = "0.28", features = ["abi3-py310", "chrono"] } +pyo3 = { version = "0.28", features = ["abi3-py310", "chrono", "uuid"] } chrono.workspace = true pyo3-async-runtimes = { version = "0.28", features = [ "attributes", @@ -40,6 +41,7 @@ serde.workspace = true serde_json.workspace = true snafu.workspace = true tokio.workspace = true +uuid.workspace = true libc = "0.2" [build-dependencies] diff --git a/python/pyproject.toml b/python/pyproject.toml index 22a41a8a9..dc5ea603d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -63,7 +63,7 @@ tests = [ "polars>=0.19, <=1.32.3", "pyarrow<25", "pyarrow-stubs>=16.0", - "pylance==9.0.0rc1", + "pylance==9.0.0", "requests>=2.31.0", "datafusion>=54,<55", "opentelemetry-sdk>=1.30.0", @@ -139,6 +139,7 @@ include = [ "python/lancedb/exceptions.py", "python/lancedb/background_loop.py", "python/lancedb/schema.py", + "python/lancedb/sql.py", "python/lancedb/remote/__init__.py", "python/lancedb/remote/errors.py", "python/lancedb/embeddings/__init__.py", diff --git a/python/python/lancedb/__init__.py b/python/python/lancedb/__init__.py index 8adadccba..f9999dc2f 100644 --- a/python/python/lancedb/__init__.py +++ b/python/python/lancedb/__init__.py @@ -22,7 +22,11 @@ from .remote.db import RemoteDBConnection from .expr import Expr, col, lit, func from .schema import blob, vector from .job import AsyncJob, Job +from .sql import AsyncQuery as AsyncSqlQuery +from .sql import Query as SqlQuery +from .sql import QueryDescription from .functions import ( + AssignmentMapping as AssignmentMapping, FunctionArtifactRequest as FunctionArtifactRequest, FunctionApplication as FunctionApplication, FunctionBinding as FunctionBinding, @@ -33,6 +37,8 @@ from .functions import ( UdfDefinition as UdfDefinition, udf as udf, ) +from .secrets import EnvVarSecret as EnvVarSecret +from .secrets import SecretInfo as SecretInfo from .materialized_view import ( AsyncMaterializedView, MaterializedView, @@ -48,6 +54,14 @@ from .namespace import ( AsyncLanceNamespaceDBConnection, ) +from .catalog import ( + AsyncCatalog, + Catalog, + ListDatabasesResponse, + connect_catalog, + connect_catalog_async, +) + if TYPE_CHECKING: from lance.blob import BlobType as BlobType @@ -101,6 +115,7 @@ def connect( api_key: Optional[str] = None, region: str = "us-east-1", host_override: Optional[str] = None, + sql_host_override: Optional[str] = None, read_consistency_interval: Optional[timedelta] = None, request_thread_pool: Optional[Union[int, ThreadPoolExecutor]] = None, client_config: Union[ClientConfig, Dict[str, Any], None] = None, @@ -129,6 +144,9 @@ def connect( The region to use for LanceDB Cloud. host_override: str, optional The override url for LanceDB Cloud. + sql_host_override: str, optional + The remote SQL service endpoint override. The client connects lazily when SQL + is first executed and retains that connection. read_consistency_interval: timedelta, default None The interval at which to check for updates to the table from other processes. If None, then consistency is not checked. For performance @@ -270,6 +288,7 @@ def connect( api_key, region, host_override, + sql_host_override=sql_host_override, # TODO: remove this (deprecation warning downstream) request_thread_pool=request_thread_pool, client_config=client_config, @@ -412,6 +431,7 @@ def deserialize_conn( parsed["api_key"], parsed.get("region", "us-east-1"), host_override=parsed.get("host_override"), + sql_host_override=parsed.get("sql_host_override"), client_config=parsed.get("client_config"), storage_options=storage_options, ) @@ -425,6 +445,7 @@ async def connect_async( api_key: Optional[str] = None, region: str = "us-east-1", host_override: Optional[str] = None, + sql_host_override: Optional[str] = None, read_consistency_interval: Optional[timedelta] = None, client_config: Optional[Union[ClientConfig, Dict[str, Any]]] = None, storage_options: Optional[Dict[str, str]] = None, @@ -447,6 +468,9 @@ async def connect_async( The region to use for LanceDB Cloud. host_override: str, optional The override url for LanceDB Cloud. + sql_host_override: str, optional + The remote SQL service endpoint override. The client connects lazily when SQL + is first executed and retains that connection. read_consistency_interval: timedelta, default None The interval at which to check for updates to the table from other processes. If None, then consistency is not checked. For performance @@ -534,6 +558,7 @@ async def connect_async( api_key, region, host_override, + sql_host_override, read_consistency_interval_secs, client_config, storage_options, @@ -546,6 +571,11 @@ async def connect_async( __all__ = [ + "Catalog", + "AsyncCatalog", + "ListDatabasesResponse", + "connect_catalog", + "connect_catalog_async", "AsyncMaterializedView", "MaterializedView", "MaterializedViewDefinition", @@ -556,6 +586,7 @@ __all__ = [ "connect_namespace_async", "AsyncConnection", "AsyncJob", + "AsyncSqlQuery", "AsyncLanceNamespaceDBConnection", "AsyncTable", "CompactionOptions", @@ -571,6 +602,8 @@ __all__ = [ "vector", "DBConnection", "Job", + "QueryDescription", + "SqlQuery", "LanceDBConnection", "LanceNamespaceDBConnection", "LsmWriteSpec", diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 93760a25f..38dd7ec81 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -1,6 +1,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal from typing import Dict, List, Optional, Tuple, Any, TypedDict, Union, Literal +from uuid import UUID import pyarrow as pa @@ -147,15 +148,35 @@ class Connection(object): start_after: Optional[str], limit: Optional[int], ) -> list[str]: ... # Deprecated: Use list_tables instead - def job(self, job_id: str) -> Job: ... + async def open_job(self, job_id: str) -> Job: ... async def create_function_async(self, request_json: str) -> Job: ... async def get_function(self, name: str, version: str) -> str: ... + async def list_functions(self) -> List[str]: ... + async def drop_function(self, name: str, version: str) -> bool: ... + async def create_secret( + self, name: str, value: str, namespace_path: Optional[List[str]] = None + ) -> None: ... + async def alter_secret( + self, name: str, value: str, namespace_path: Optional[List[str]] = None + ) -> None: ... + async def list_secrets( + self, namespace_path: Optional[List[str]] = None + ) -> List[str]: ... + async def drop_secret( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> None: ... + async def describe_secret( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Tuple[str, int, int]: ... async def list_jobs(self) -> List[JobInfo]: ... - async def get_job(self, job_id: str) -> Optional[JobDescription]: ... async def cancel_job(self, job_id: str) -> bool: ... - async def job_history( - self, job_id: Optional[str] = None - ) -> List[pa.RecordBatch]: ... + async def execute_query_async( + self, + query: str, + *, + default_namespace_path: Optional[List[str]] = None, + ) -> SqlQuery: ... + async def describe_query(self, query_id: UUID) -> QueryDescription: ... async def create_table( self, name: str, @@ -205,8 +226,24 @@ class Connection(object): projections: Optional[List[Tuple[str, str]]] = None, filter: Optional[str] = None, limit: Optional[int] = None, + with_no_data: bool = False, ) -> Table: ... + async def create_materialized_view_async( + self, + name: str, + source: str, + projections: Optional[List[Tuple[str, str]]] = None, + filter: Optional[str] = None, + limit: Optional[int] = None, + with_no_data: bool = False, + ) -> Job: ... async def list_materialized_views(self) -> List[str]: ... + async def drop_materialized_view( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> None: ... + async def drop_materialized_view_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job: ... async def drop_table( self, name: str, namespace_path: Optional[List[str]] = None ) -> None: ... @@ -234,9 +271,20 @@ class BlobFile: class Job: @property def id(self) -> Optional[str]: ... + @property + def _state(self) -> Optional[str]: ... + @property + def _description(self) -> Optional[JobDescription]: ... async def status(self) -> str: ... async def wait(self) -> Optional[str]: ... async def cancel(self) -> None: ... + async def refresh(self) -> None: ... + async def events( + self, + *, + limit: Optional[int] = None, + filter: Optional[str] = None, + ) -> pa.Table: ... class JobInfo: @property @@ -250,6 +298,37 @@ class JobInfo: @property def created_at_millis(self) -> int: ... +class SessionStatus: + @property + def resource(self) -> Optional[str]: ... + @property + def audience(self) -> Optional[str]: ... + @property + def refreshable(self) -> bool: ... + @property + def issuer_url(self) -> str: ... + @property + def client_id(self) -> str: ... + @property + def scopes(self) -> List[str]: ... + @property + def flow(self) -> str: ... + @property + def obtained_at(self) -> Optional[int]: ... + def __repr__(self) -> str: ... + +class SessionLogout: + @property + def removed(self) -> bool: ... + def __repr__(self) -> str: ... + +class OAuthSession: + def __init__(self, config: Any) -> None: ... + async def login(self) -> SessionStatus: ... + async def status(self) -> SessionStatus: ... + async def logout(self) -> SessionLogout: ... + def __repr__(self) -> str: ... + class JobFailureInfo: @property def phase(self) -> Optional[str]: ... @@ -268,10 +347,33 @@ class JobDescription: @property def creation_ms(self) -> int: ... @property - def spec_json(self) -> Optional[str]: ... + def _spec_json(self) -> Optional[str]: ... + @property + def _result_json(self) -> Optional[str]: ... + @property + def spec(self) -> Optional[Any]: ... + @property + def result(self) -> Optional[Any]: ... @property def failure(self) -> Optional[JobFailureInfo]: ... +class SqlQuery: + @property + def id(self) -> UUID: ... + async def describe(self) -> QueryDescription: ... + async def reader(self) -> RecordBatchStream: ... + async def cancel(self) -> None: ... + +class QueryDescription: + @property + def id(self) -> UUID: ... + @property + def status(self) -> str: ... + @property + def progress(self) -> Optional[float]: ... + @property + def expires_at(self) -> Optional[datetime]: ... + class Table: def name(self) -> str: ... def __repr__(self) -> str: ... @@ -362,6 +464,10 @@ class Table: async def refresh_materialized_view( self, full: bool = False, source_version: Optional[int] = None ) -> RefreshMaterializedViewResult: ... + async def refresh_materialized_view_async( + self, full: bool = False, source_version: Optional[int] = None + ) -> Job: ... + async def materialized_view_definition(self) -> str: ... async def add_columns_with_schema(self, schema: pa.Schema) -> AddColumnsResult: ... async def alter_columns( self, columns: list[dict[str, Any]] @@ -451,6 +557,7 @@ async def connect( api_key: Optional[str], region: Optional[str], host_override: Optional[str], + sql_host_override: Optional[str], read_consistency_interval: Optional[float], client_config: Optional[Union[ClientConfig, Dict[str, Any]]], storage_options: Optional[Dict[str, str]], @@ -607,6 +714,7 @@ class FullTextQuery: class PyQueryRequest: limit: Optional[int] offset: Optional[int] + take_offsets: Optional[List[int]] filter: Optional[Union[str, bytes]] full_text_search: Optional[FullTextQuery] select: Optional[Union[str, List[str]]] @@ -714,6 +822,8 @@ class RefreshColumnResult: version: int class RefreshMaterializedViewResult: + @staticmethod + def from_json(value: str) -> RefreshMaterializedViewResult: ... mode: str rows_written: int source_version: int @@ -762,3 +872,27 @@ def fts_query_to_json(query: Any) -> str: ... class PermutationReader: def __init__(self, base_table: Table, permutation_table: Table): ... + +class Catalog: + @property + def uri(self) -> str: ... + async def create_database( + self, name: str, *, exist_ok: bool = False + ) -> Connection: ... + async def connect_database(self, name: str) -> Connection: ... + async def drop_database( + self, name: str, *, ignore_missing: bool = False + ) -> None: ... + async def list_databases( + self, *, limit: Optional[int] = None, page_token: Optional[str] = None + ) -> tuple[list[str], Optional[str]]: ... + +async def connect_catalog( + endpoint: str, + *, + api_key: Optional[str] = None, + client_config: Optional[Any] = None, + sql_host_override: Optional[str] = None, + read_consistency_interval: Optional[float] = None, + oauth_config: Optional[Any] = None, +) -> Catalog: ... diff --git a/python/python/lancedb/catalog.py b/python/python/lancedb/catalog.py new file mode 100644 index 000000000..4fd941ddd --- /dev/null +++ b/python/python/lancedb/catalog.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +"""Remote catalogs manage databases through a server's root namespace.""" + +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Optional, Union + +from . import _lancedb +from .background_loop import LOOP +from .db import AsyncConnection, DBConnection +from .remote import ClientConfig, OAuthConfig +from .remote.db import RemoteDBConnection + + +@dataclass +class ListDatabasesResponse: + """A page of database names and an optional continuation token.""" + + databases: list[str] + page_token: Optional[str] = None + + +class AsyncCatalog: + """An asynchronous remote catalog returned by + [connect_catalog_async][lancedb.connect_catalog_async]. + + Create/connect return ordinary [AsyncConnection][lancedb.db.AsyncConnection] + instances. Drop uses restricted behavior: remove the database's tables first. + """ + + def __init__(self, inner: _lancedb.Catalog): + self._inner = inner + + @property + def uri(self) -> str: + """The catalog's root namespace endpoint.""" + return self._inner.uri + + async def create_database( + self, name: str, *, exist_ok: bool = False + ) -> AsyncConnection: + """Create a database, or open an existing one when ``exist_ok=True``.""" + return AsyncConnection( + await self._inner.create_database(name, exist_ok=exist_ok) + ) + + async def connect_database(self, name: str) -> AsyncConnection: + """Connect to an existing database by its logical name.""" + return AsyncConnection(await self._inner.connect_database(name)) + + async def list_databases( + self, *, limit: Optional[int] = None, page_token: Optional[str] = None + ) -> ListDatabasesResponse: + """List a page of databases. Pass the returned token for the next page.""" + names, token = await self._inner.list_databases( + limit=limit, page_token=page_token + ) + return ListDatabasesResponse(names, token) + + async def drop_database(self, name: str, *, ignore_missing: bool = False) -> None: + """Drop an empty database. A nonempty database is an error.""" + await self._inner.drop_database(name, ignore_missing=ignore_missing) + + +class Catalog: + """A synchronous remote catalog returned by + [connect_catalog][lancedb.connect_catalog]. + + Examples + -------- + ```python + catalog = lancedb.connect_catalog("https://my-server.example", api_key="secret") + db = catalog.create_database("analytics", exist_ok=True) + page = catalog.list_databases(limit=20) + ``` + """ + + def __init__( + self, + inner: AsyncCatalog, + *, + api_key=None, + client_config=None, + sql_host_override: Optional[str] = None, + oauth_config: Optional[OAuthConfig] = None, + ): + self._inner = inner + self._api_key = api_key + self._client_config = client_config + self._sql_host_override = sql_host_override + self._oauth_config = oauth_config + + @property + def uri(self) -> str: + """The catalog's root namespace endpoint.""" + return self._inner.uri + + def create_database(self, name: str, *, exist_ok: bool = False) -> DBConnection: + """Create a database, or open an existing one when ``exist_ok=True``.""" + inner = LOOP.run(self._inner.create_database(name, exist_ok=exist_ok)) + return self._wrap_database(name, inner) + + def connect_database(self, name: str) -> DBConnection: + """Connect to an existing database by its logical name.""" + return self._wrap_database(name, LOOP.run(self._inner.connect_database(name))) + + def _wrap_database(self, name: str, inner: AsyncConnection) -> DBConnection: + return RemoteDBConnection._from_catalog( + inner, + name, + self.uri, + self._api_key, + self._client_config, + self._oauth_config, + self._sql_host_override, + ) + + def list_databases( + self, *, limit: Optional[int] = None, page_token: Optional[str] = None + ) -> ListDatabasesResponse: + """List a page of databases. Pass the returned token for the next page.""" + return LOOP.run(self._inner.list_databases(limit=limit, page_token=page_token)) + + def drop_database(self, name: str, *, ignore_missing: bool = False) -> None: + """Drop an empty database. A nonempty database is an error.""" + LOOP.run(self._inner.drop_database(name, ignore_missing=ignore_missing)) + + +async def connect_catalog_async( + endpoint: str, + *, + api_key: Optional[str] = None, + client_config: Optional[Union[ClientConfig, dict[str, Any]]] = None, + sql_host_override: Optional[str] = None, + read_consistency_interval: Optional[timedelta] = None, + oauth_config: Optional[OAuthConfig] = None, +) -> AsyncCatalog: + """Connect to an HTTP(S) server's root catalog. + + Root requests omit database-selection headers. API key, client configuration, + OAuth, and table read consistency settings are inherited by opened databases. + Database names containing slashes remain single logical names. + Set ``sql_host_override`` to the SQL service endpoint to execute SQL through + returned connections when the catalog endpoint uses HTTPS. + """ + if isinstance(client_config, dict): + client_config = ClientConfig(**client_config) + if client_config is None: + client_config = ClientConfig() + inner = await _lancedb.connect_catalog( + endpoint, + api_key=api_key, + client_config=client_config, + sql_host_override=sql_host_override, + read_consistency_interval=( + read_consistency_interval.total_seconds() + if read_consistency_interval is not None + else None + ), + oauth_config=oauth_config, + ) + return AsyncCatalog(inner) + + +def connect_catalog( + endpoint: str, + *, + api_key: Optional[str] = None, + client_config: Optional[Union[ClientConfig, dict[str, Any]]] = None, + sql_host_override: Optional[str] = None, + read_consistency_interval: Optional[timedelta] = None, + oauth_config: Optional[OAuthConfig] = None, +) -> Catalog: + """Connect synchronously to an HTTP(S) server's root catalog. + + See [connect_catalog_async][lancedb.connect_catalog_async] for options. + Local filesystem and object-store catalogs are not supported. + """ + return Catalog( + LOOP.run( + connect_catalog_async( + endpoint, + api_key=api_key, + client_config=client_config, + sql_host_override=sql_host_override, + read_consistency_interval=read_consistency_interval, + oauth_config=oauth_config, + ) + ), + api_key=api_key, + client_config=client_config, + sql_host_override=sql_host_override, + oauth_config=oauth_config, + ) diff --git a/python/python/lancedb/db.py b/python/python/lancedb/db.py index 51b8d9993..d17e897cf 100644 --- a/python/python/lancedb/db.py +++ b/python/python/lancedb/db.py @@ -17,8 +17,10 @@ from typing import ( List, Literal, Optional, + Sequence, Union, ) +from uuid import UUID if sys.version_info >= (3, 12): from typing import override @@ -47,12 +49,21 @@ from . import __version__ from ._lancedb import connect as lancedb_connect # type: ignore from .functions import FunctionVersion, UdfDefinition from .job import AsyncJob, Job, _typed_job +from .sql import AsyncQuery as AsyncSqlQuery +from .sql import Query as SqlQuery +from .sql import QueryDescription from .materialized_view import ( AsyncMaterializedView, MaterializedView, SelectArg, normalize_select, ) +from .secrets import ( + EnvVarSecret, + SecretInfo, + validate_namespace_path, + validate_secret_name, +) from .table import ( AsyncTable, LanceTable, @@ -68,10 +79,11 @@ import deprecation if TYPE_CHECKING: import pyarrow as pa + from .arrow import AsyncRecordBatchReader from .pydantic import LanceModel from ._lancedb import Connection as LanceDbConnection - from ._lancedb import JobDescription, JobInfo + from ._lancedb import JobInfo from .common import DATA, URI from .embeddings import EmbeddingFunctionConfig from ._lancedb import Session @@ -524,13 +536,14 @@ class DBConnection(EnforceOverrides): select: SelectArg = None, where: Optional[str] = None, limit: Optional[int] = None, + with_no_data: bool = False, ) -> MaterializedView: """Define a materialized view named ``name`` over the table ``source``. - The view is created empty, with the query recorded in its schema - metadata; ``view.refresh()`` computes the rows. The view is a normal - table: it can be queried, indexed and searched, and it appears in - ``table_names``. Local databases only. + The view is populated before creation returns. Pass + ``with_no_data=True`` to create only its definition and empty backing + table. The view is a normal table: it can be queried, indexed and + searched, and it appears in ``table_names``. The source table must have stable row ids (create it with the ``new_table_enable_stable_row_ids`` storage option): they keep the @@ -551,6 +564,8 @@ class DBConnection(EnforceOverrides): SQL predicate; only matching source rows appear in the view. limit: int, optional Cap the view at this many rows, in materialization order. + with_no_data: bool, default False + Skip the initial refresh and leave the backing table empty. Returns ------- @@ -560,6 +575,27 @@ class DBConnection(EnforceOverrides): "materialized views are not supported on this connection type" ) + def create_materialized_view_async( + self, + name: str, + source: str, + *, + select: SelectArg = None, + where: Optional[str] = None, + limit: Optional[int] = None, + with_no_data: bool = False, + ) -> Job[None]: + """Submit materialized-view creation and return its job. + + The job may already be complete for a local database. On LanceDB + Cloud and Enterprise, its ``id`` is the server job identifier from + the ``202 Accepted`` create response. Wait for the job before opening + or querying the view. + """ + raise NotImplementedError( + "materialized views are not supported on this connection type" + ) + def open_materialized_view(self, name: str) -> MaterializedView: """Open the materialized view named ``name``. @@ -580,6 +616,32 @@ class DBConnection(EnforceOverrides): "materialized views are not supported on this connection type" ) + def drop_materialized_view( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> None: + """Drop a materialized view. + + The view may become unavailable before physical cleanup finishes. Use + :meth:`drop_materialized_view_async` to retain and wait for the cleanup + job. + """ + raise NotImplementedError( + "materialized views are not supported on this connection type" + ) + + def drop_materialized_view_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job[None]: + """Start dropping a materialized view and return its cleanup job. + + The job may already be complete for a local database. On LanceDB Cloud + and Enterprise, its ``id`` is the server job identifier from the + ``202 Accepted`` drop response. + """ + raise NotImplementedError( + "materialized views are not supported on this connection type" + ) + def drop_table(self, name: str, namespace_path: Optional[List[str]] = None): """Drop a table from the database. @@ -687,20 +749,55 @@ class DBConnection(EnforceOverrides): """ raise NotImplementedError("serialize is not supported for this connection type") - def create_function(self, definition: UdfDefinition) -> FunctionVersion: - """Register a scalar Python UDF and wait for its immutable version. + def create_function( + self, + definition: UdfDefinition, + *, + secrets: Optional[Sequence[EnvVarSecret]] = None, + ) -> FunctionVersion: + """Build and register a scalar Python UDF, then return its version. + The server builds the OCI image and registers the completed artifact. This is the blocking counterpart of :meth:`create_function_async`. Local connections raise ``NotImplementedError``. + + Parameters + ---------- + definition : UdfDefinition + A callable decorated with [udf][lancedb.udf]. + secrets : sequence of EnvVarSecret, optional + One [EnvVarSecret][lancedb.secrets.EnvVarSecret] per credential the + Function needs, each naming a Secret and the environment variable + its value arrives in. The Function's source is unchanged by this; + it reads the variable the way it already did. + + Examples + -------- + ```python + db.create_secret("openai-prod", os.environ["OPENAI_API_KEY"]) + db.create_function( + analyze_caption, + secrets=[ + EnvVarSecret( + secret_name="openai-prod", env_variable="OPENAI_API_KEY" + ) + ], + ) + ``` """ - return self.create_function_async(definition).wait() + return self.create_function_async(definition, secrets=secrets).wait() - def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: - """Register a scalar Python UDF through the remote Function catalog. + def create_function_async( + self, + definition: UdfDefinition, + *, + secrets: Optional[Sequence[EnvVarSecret]] = None, + ) -> Job[FunctionVersion]: + """Submit a scalar Python UDF for building and registration. - Submission returns a typed job. The immutable Function version becomes - available only when :meth:`Job.wait` succeeds. Local connections raise - ``NotImplementedError``. + The server-side job builds the OCI image, then registers the completed + artifact. Waiting on the job returns the immutable Function version. + Local connections raise ``NotImplementedError``. """ raise NotImplementedError( "Function catalog operations are not supported for this connection type" @@ -712,26 +809,117 @@ class DBConnection(EnforceOverrides): "Function catalog operations are not supported for this connection type" ) - def job(self, job_id: str) -> Job: - """A [Job][lancedb.job.Job] handle for a server-side job by id. + def list_functions(self) -> List[FunctionVersion]: + """List every published immutable Function version. - The handle is constructed without a server round trip; an unknown id - surfaces when the handle is used. Dropping the handle has no effect - on the job itself. + Results are ordered by Function name then version. Local connections + raise ``NotImplementedError``. + + Examples + -------- + List the identities available to use in Function-backed columns: + + ```python + [(function.name, function.version) for function in db.list_functions()] + ``` """ - raise NotImplementedError("job is not supported for this connection type") + raise NotImplementedError( + "Function catalog operations are not supported for this connection type" + ) + + def drop_function(self, name: str, *, version: str) -> bool: + """Remove the current Function name binding from the remote catalog. + + The requested version must exist in the currently named object. + Object history and existing computed-column references are retained. + Returns True when the name was removed and False when it was absent. + Local connections raise NotImplementedError. + """ + raise NotImplementedError( + "Function catalog operations are not supported for this connection type" + ) + + def create_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + """Create a named Secret in this database. + + Fails if the name is taken, so a create never silently becomes a + rotation. Nothing reads the value back: it is bound to a Function by + name and resolved by the service when that Function runs. Local + connections raise ``NotImplementedError``. + """ + raise NotImplementedError( + "Secret operations are not supported for this connection type" + ) + + def alter_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + """Replace the credential behind an existing Secret. + + Fails if it does not exist. Every Function bound to the Secret uses the + new value from its next job, and no new Function version is created -- + which is how a rotation reaches columns pinned to a version registered + before it. Local connections raise ``NotImplementedError``. + """ + raise NotImplementedError( + "Secret operations are not supported for this connection type" + ) + + def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]: + """The names of every Secret in this database. + + Names only. No method returns a stored credential, by construction + rather than by policy. Local connections raise ``NotImplementedError``. + """ + raise NotImplementedError( + "Secret operations are not supported for this connection type" + ) + + def drop_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + """Drop a Secret. + + Functions bound to it fail at their next job, naming the Secret; that + is the revocation path. The name becomes free to reuse, and a new + Secret under it is picked up by everything still bound to that name. + Local connections raise ``NotImplementedError``. + """ + raise NotImplementedError( + "Secret operations are not supported for this connection type" + ) + + def describe_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> SecretInfo: + """What this database records about a Secret: name and timestamps. + + Never the value -- there is no code path that could return one. Local + connections raise ``NotImplementedError``. + """ + raise NotImplementedError( + "Secret operations are not supported for this connection type" + ) + + def open_job(self, job_id: str) -> Job: + """Open a server-side job by id, returning a handle with its record + already populated. + + The returned [Job][lancedb.job.Job] answers for its own state, + specification, result, failure and event history, so there is no + separate connection-level call for any of them. + + Raises `JobNotFoundError` when the server has no such job, the way + `open_table` does for a missing table. + """ + raise NotImplementedError("open_job is not supported for this connection type") def list_jobs(self) -> List[JobInfo]: """List server-side jobs across the database's tables.""" raise NotImplementedError("list_jobs is not supported for this connection type") - def get_job(self, job_id: str) -> Optional[JobDescription]: - """Describe a single server-side job by id. - - Returns None when the server has no such job. - """ - raise NotImplementedError("get_job is not supported for this connection type") - def cancel_job(self, job_id: str) -> bool: """Request cancellation of a server-side job by id. @@ -743,14 +931,38 @@ class DBConnection(EnforceOverrides): "cancel_job is not supported for this connection type" ) - def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]: - """The lifecycle event history of a server-side job, as Arrow batches. + def execute_query( + self, + query: str, + *, + default_namespace_path: Optional[List[str]] = None, + ) -> pa.RecordBatchReader: + """Execute SQL and return a blocking Arrow reader. - Lists history across all jobs when `job_id` is None. + This submits through :meth:`execute_query_async` and waits until the + initial result stream is readable. It does not wait for the full query + to finish. """ - raise NotImplementedError( - "job_history is not supported for this connection type" - ) + return self.execute_query_async( + query, + default_namespace_path=default_namespace_path, + ).reader() + + def execute_query_async( + self, + query: str, + *, + default_namespace_path: Optional[List[str]] = None, + ) -> SqlQuery: + """Start executing SQL and return its query handle. + + Local connections do not support SQL. + """ + raise NotImplementedError("SQL is not supported for this connection type") + + def describe_query(self, query_id: UUID) -> QueryDescription: + """Describe a submitted SQL query by its connection-scoped id.""" + raise NotImplementedError("SQL is not supported for this connection type") class LanceDBConnection(DBConnection): @@ -847,6 +1059,7 @@ class LanceDBConnection(DBConnection): None, None, None, + None, read_consistency_interval_secs, None, storage_options, @@ -1215,6 +1428,7 @@ class LanceDBConnection(DBConnection): select: SelectArg = None, where: Optional[str] = None, limit: Optional[int] = None, + with_no_data: bool = False, ) -> MaterializedView: """Define a materialized view named ``name`` over the table ``source``. See @@ -1235,17 +1449,44 @@ class LanceDBConnection(DBConnection): ... select=["name", ("shout", "upper(name)")], ... where="age >= 18", ... ) - >>> result = view.refresh() - >>> result.rows_written + >>> view.table.count_rows() 1 """ LOOP.run( self._conn.create_materialized_view( - name, source, select=select, where=where, limit=limit + name, + source, + select=select, + where=where, + limit=limit, + with_no_data=with_no_data, ) ) return MaterializedView(self.open_table(name)) + @override + def create_materialized_view_async( + self, + name: str, + source: str, + *, + select: SelectArg = None, + where: Optional[str] = None, + limit: Optional[int] = None, + with_no_data: bool = False, + ) -> Job[None]: + job = LOOP.run( + self._conn.create_materialized_view_async( + name, + source, + select=select, + where=where, + limit=limit, + with_no_data=with_no_data, + ) + ) + return Job(job) + @override def open_materialized_view(self, name: str) -> MaterializedView: """Open the materialized view named ``name``.""" @@ -1258,6 +1499,25 @@ class LanceDBConnection(DBConnection): """The names of the materialized views in this database.""" return LOOP.run(self._conn.list_materialized_views()) + @override + def drop_materialized_view( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> None: + if namespace_path is None: + namespace_path = [] + LOOP.run(self._conn.drop_materialized_view(name, namespace_path=namespace_path)) + + @override + def drop_materialized_view_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job[None]: + if namespace_path is None: + namespace_path = [] + job = LOOP.run( + self._conn.drop_materialized_view_async(name, namespace_path=namespace_path) + ) + return Job(job) + def clone_table( self, target_table_name: str, @@ -1395,37 +1655,67 @@ class LanceDBConnection(DBConnection): ) @override - def job(self, job_id: str) -> Job: - """A [Job][lancedb.job.Job] handle for a server-side job by id. - - The handle is constructed without a server round trip; an unknown id - surfaces when the handle is used. Dropping the handle has no effect - on the job itself. + def open_job(self, job_id: str) -> Job: + """Open a server-side job by id. See + [DBConnection.open_job][lancedb.db.DBConnection.open_job]. """ - return Job(self._conn.job(job_id)) + return Job(LOOP.run(self._conn.open_job(job_id))) @override - def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: - job = LOOP.run(self._conn.create_function_async(definition)) + def create_function_async( + self, + definition: UdfDefinition, + *, + secrets: Optional[Sequence[EnvVarSecret]] = None, + ) -> Job[FunctionVersion]: + job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets)) return Job(job) @override def get_function(self, name: str, *, version: str) -> FunctionVersion: return LOOP.run(self._conn.get_function(name, version=version)) + @override + def list_functions(self) -> List[FunctionVersion]: + return LOOP.run(self._conn.list_functions()) + + @override + def drop_function(self, name: str, *, version: str) -> bool: + return LOOP.run(self._conn.drop_function(name, version=version)) + + @override + def create_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path)) + + @override + def alter_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path)) + + @override + def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]: + return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path)) + + @override + def drop_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path)) + + @override + def describe_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> SecretInfo: + return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path)) + @override def list_jobs(self) -> List[JobInfo]: """List server-side jobs across the database's tables.""" return LOOP.run(self._conn.list_jobs()) - @override - def get_job(self, job_id: str) -> Optional[JobDescription]: - """Describe a single server-side job by id. - - Returns None when the server has no such job. - """ - return LOOP.run(self._conn.get_job(job_id)) - @override def cancel_job(self, job_id: str) -> bool: """Request cancellation of a server-side job by id. @@ -1436,14 +1726,6 @@ class LanceDBConnection(DBConnection): """ return LOOP.run(self._conn.cancel_job(job_id)) - @override - def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]: - """The lifecycle event history of a server-side job, as Arrow batches. - - Lists history across all jobs when `job_id` is None. - """ - return LOOP.run(self._conn.job_history(job_id)) - @override def namespace_client(self) -> LanceNamespace: """Get the equivalent namespace client for this connection. @@ -2036,6 +2318,7 @@ class AsyncConnection(object): select: SelectArg = None, where: Optional[str] = None, limit: Optional[int] = None, + with_no_data: bool = False, ) -> AsyncMaterializedView: """Define a materialized view named ``name`` over the table ``source``. See @@ -2047,19 +2330,40 @@ class AsyncConnection(object): projections=normalize_select(select), filter=where, limit=limit, + with_no_data=with_no_data, ) return AsyncMaterializedView(AsyncTable(inner)) + async def create_materialized_view_async( + self, + name: str, + source: str, + *, + select: SelectArg = None, + where: Optional[str] = None, + limit: Optional[int] = None, + with_no_data: bool = False, + ) -> AsyncJob[None]: + """Submit materialized-view creation and return its job. + + Wait for the returned job before opening or querying the view. + """ + inner = await self._inner.create_materialized_view_async( + name, + source, + projections=normalize_select(select), + filter=where, + limit=limit, + with_no_data=with_no_data, + ) + return AsyncJob(inner) + async def open_materialized_view(self, name: str) -> AsyncMaterializedView: """Open the materialized view named ``name``. Raises ``ValueError`` if the table exists but is not a materialized view. """ - if self.uri.startswith("db://"): - raise NotImplementedError( - "materialized views are supported only on local databases" - ) view = AsyncMaterializedView(await self.open_table(name)) await view.definition() return view @@ -2072,6 +2376,41 @@ class AsyncConnection(object): """ return await self._inner.list_materialized_views() + async def drop_materialized_view( + self, + name: str, + *, + namespace_path: Optional[List[str]] = None, + ) -> None: + """Drop a materialized view. + + The view may become unavailable before physical cleanup finishes. Use + :meth:`drop_materialized_view_async` to retain and wait for the cleanup + job. + """ + if namespace_path is None: + namespace_path = [] + await self._inner.drop_materialized_view(name, namespace_path=namespace_path) + + async def drop_materialized_view_async( + self, + name: str, + *, + namespace_path: Optional[List[str]] = None, + ) -> AsyncJob[None]: + """Start dropping a materialized view and return its cleanup job. + + Await :meth:`AsyncJob.wait` before assuming physical cleanup has + finished. + """ + if namespace_path is None: + namespace_path = [] + return AsyncJob( + await self._inner.drop_materialized_view_async( + name, namespace_path=namespace_path + ) + ) + async def clone_table( self, target_table_name: str, @@ -2214,46 +2553,114 @@ class AsyncConnection(object): namespace_path = [] await self._inner.drop_all_tables(namespace_path=namespace_path) - def job(self, job_id: str) -> AsyncJob: - """An [AsyncJob][lancedb.job.AsyncJob] handle for a server-side job - by id. - - The handle is constructed without a server round trip; an unknown id - surfaces when the handle is used. Dropping the handle has no effect - on the job itself. + async def open_job(self, job_id: str) -> AsyncJob: + """Open a server-side job by id. See + [DBConnection.open_job][lancedb.db.DBConnection.open_job]. """ - return AsyncJob(self._inner.job(job_id)) + return AsyncJob(await self._inner.open_job(job_id)) async def create_function_async( - self, definition: UdfDefinition + self, + definition: UdfDefinition, + *, + secrets: Optional[Sequence[EnvVarSecret]] = None, ) -> AsyncJob[FunctionVersion]: - """Register a scalar Python UDF through the remote Function catalog. + """Submit a scalar Python UDF for building and registration. - The returned typed job resolves to the immutable Function version. - Local connections raise ``NotImplementedError``. + The server-side job builds the OCI image, then registers the completed + artifact. Waiting on the job returns the immutable Function version. + ``secrets`` is a sequence of + [EnvVarSecret][lancedb.secrets.EnvVarSecret], each naming a Secret and + the environment variable its value arrives in. Local connections raise + ``NotImplementedError``. """ if not isinstance(definition, UdfDefinition): raise TypeError("create_function_async requires a @udf definition") - inner = await self._inner.create_function_async( - definition.registration_request.to_canonical_json() - ) + request = definition.bind_secrets(secrets) + inner = await self._inner.create_function_async(request.to_canonical_json()) return _typed_job(inner, FunctionVersion.from_json) async def get_function(self, name: str, *, version: str) -> FunctionVersion: """Open one exact immutable Function version from the remote catalog.""" return FunctionVersion.from_json(await self._inner.get_function(name, version)) + async def list_functions(self) -> List[FunctionVersion]: + """List every published immutable Function version. + + Results are ordered by Function name then version. Local connections + raise ``NotImplementedError``. + """ + return [ + FunctionVersion.from_json(value) + for value in await self._inner.list_functions() + ] + + async def drop_function(self, name: str, *, version: str) -> bool: + """Remove the current name binding, retaining the object and its history.""" + return await self._inner.drop_function(name, version) + + async def create_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + """Create a named Secret in this database. + + Fails if the name is taken, so a create never silently becomes a + rotation. Nothing reads the value back. + """ + await self._inner.create_secret( + validate_secret_name(name), + value, + list(validate_namespace_path(namespace_path)), + ) + + async def alter_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + """Replace the credential behind an existing Secret. + + Fails if it does not exist. Bound Functions use the new value from + their next job, with no new Function version. + """ + await self._inner.alter_secret( + validate_secret_name(name), + value, + list(validate_namespace_path(namespace_path)), + ) + + async def list_secrets( + self, *, namespace_path: Optional[List[str]] = None + ) -> List[str]: + """The names of every Secret in this database. Names only.""" + return await self._inner.list_secrets( + list(validate_namespace_path(namespace_path)) + ) + + async def drop_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + """Drop a Secret. Bound Functions fail at their next job.""" + await self._inner.drop_secret( + validate_secret_name(name), list(validate_namespace_path(namespace_path)) + ) + + async def describe_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> SecretInfo: + """What this database records about a Secret. Never the value.""" + name, created_at_millis, updated_at_millis = await self._inner.describe_secret( + validate_secret_name(name), + list(validate_namespace_path(namespace_path)), + ) + return SecretInfo( + name=name, + created_at_millis=created_at_millis, + updated_at_millis=updated_at_millis, + ) + async def list_jobs(self) -> List[JobInfo]: """List server-side jobs across the database's tables.""" return await self._inner.list_jobs() - async def get_job(self, job_id: str) -> Optional[JobDescription]: - """Describe a single server-side job by id. - - Returns None when the server has no such job. - """ - return await self._inner.get_job(job_id) - async def cancel_job(self, job_id: str) -> bool: """Request cancellation of a server-side job by id. @@ -2263,12 +2670,46 @@ class AsyncConnection(object): """ return await self._inner.cancel_job(job_id) - async def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]: - """The lifecycle event history of a server-side job, as Arrow batches. + async def execute_query( + self, + query: str, + *, + default_namespace_path: Optional[List[str]] = None, + ) -> AsyncRecordBatchReader: + """Execute SQL and return an asynchronous Arrow reader. - Lists history across all jobs when `job_id` is None. + This submits through :meth:`execute_query_async` and waits until the + initial result stream is readable. It does not wait for the full query + to finish. """ - return await self._inner.job_history(job_id) + submitted = await self.execute_query_async( + query, + default_namespace_path=default_namespace_path, + ) + return await submitted.reader() + + async def execute_query_async( + self, + query: str, + *, + default_namespace_path: Optional[List[str]] = None, + ) -> AsyncSqlQuery: + """Start executing SQL and return its query handle. + + The database from ``connect_async`` is used for unqualified database + references. The namespace defaults to ``["public"]``. Local + connections raise ``NotImplementedError``. + """ + return AsyncSqlQuery( + await self._inner.execute_query_async( + query, + default_namespace_path=default_namespace_path, + ) + ) + + async def describe_query(self, query_id: UUID) -> QueryDescription: + """Describe a submitted SQL query by its connection-scoped id.""" + return await self._inner.describe_query(query_id) async def namespace_client(self) -> LanceNamespace: """Get the equivalent namespace client for this connection. diff --git a/python/python/lancedb/embeddings/gte.py b/python/python/lancedb/embeddings/gte.py index 9bad4b54f..10dcaf456 100644 --- a/python/python/lancedb/embeddings/gte.py +++ b/python/python/lancedb/embeddings/gte.py @@ -21,7 +21,7 @@ class GteEmbeddings(TextEmbeddingFunction): An embedding function that uses GTE-LARGE MLX format(for Apple silicon devices only) as well as the standard cpu/gpu version from: https://huggingface.co/thenlper/gte-large. - For Apple users, you will need the mlx package insalled, which can be done with: + For Apple users, you will need the mlx package installed, which can be done with: pip install mlx Parameters diff --git a/python/python/lancedb/embeddings/instructor.py b/python/python/lancedb/embeddings/instructor.py index 37ae1c296..a7a2b7e96 100644 --- a/python/python/lancedb/embeddings/instructor.py +++ b/python/python/lancedb/embeddings/instructor.py @@ -60,7 +60,7 @@ class InstructorEmbeddingFunction(TextEmbeddingFunction): import lancedb from lancedb.pydantic import LanceModel, Vector - from lancedb.embeddings import get_registry, InstuctorEmbeddingFunction + from lancedb.embeddings import get_registry, InstructorEmbeddingFunction instructor = get_registry().get("instructor").create( source_instruction="represent the document for retrieval", diff --git a/python/python/lancedb/embeddings/utils.py b/python/python/lancedb/embeddings/utils.py index 189bbe53c..98806fa52 100644 --- a/python/python/lancedb/embeddings/utils.py +++ b/python/python/lancedb/embeddings/utils.py @@ -249,7 +249,7 @@ def retry_with_exponential_backoff( initial_delay (float): Initial delay in seconds (default is 1). exponential_base (float): The base for exponential backoff (default is 2). jitter (bool): Whether to add jitter to the delay (default is True). - max_retries (int): Maximum number of retries (default is 10). + max_retries (int): Maximum number of retries (default is 7). Returns: function: The decorated function. diff --git a/python/python/lancedb/exceptions.py b/python/python/lancedb/exceptions.py index daa98ee6e..67f15cabe 100644 --- a/python/python/lancedb/exceptions.py +++ b/python/python/lancedb/exceptions.py @@ -35,3 +35,9 @@ class JobCancelledError(RuntimeError): """Exception raised when an asynchronous job was cancelled.""" pass + + +class JobNotFoundError(ValueError): + """Exception raised when opening a job the server does not have.""" + + pass diff --git a/python/python/lancedb/functions.py b/python/python/lancedb/functions.py index 8a19a9d37..8c6864cc3 100644 --- a/python/python/lancedb/functions.py +++ b/python/python/lancedb/functions.py @@ -4,7 +4,7 @@ """Canonical Function values exchanged with LanceDB Enterprise services. These immutable models contain client/wire state only. Catalog persistence, -environment bake, and execution are owned by Sophon. +environment bake, secret resolution, and execution are owned by Sophon. ``RefreshColumnResult`` is also the backend-neutral result of a local expression-backed refresh job. """ @@ -25,7 +25,7 @@ import re import sys import textwrap import types -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import date, datetime from typing import ( Annotated, @@ -41,6 +41,7 @@ from typing import ( import pyarrow as pa from pydantic import ( + AfterValidator, BaseModel, ConfigDict, Field, @@ -49,11 +50,26 @@ from pydantic import ( model_validator, ) +from .schema import is_blob_v2_field as _is_blob_v2_field +from .secrets import EnvVarSecret + _Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1) _UInt32 = conint(strict=True, ge=0, le=2**32 - 1) _UInt64 = conint(strict=True, ge=0, le=2**64 - 1) +def _validate_gpu_wire_marker(value: Any) -> bool: + if value is not True: + raise ValueError("runtime.gpu must be true") + return True + + +def _normalize_gpu_marker(value: bool) -> Optional[bool]: + if not isinstance(value, bool): + raise ValueError("gpu must be a boolean") + return True if value else None + + class _FrozenDict(dict): def _immutable(self, *args, **kwargs): raise TypeError("remote canonical values are immutable") @@ -212,6 +228,33 @@ class FunctionOutput(_OpenRemoteValue): fields: tuple[FunctionResultField, ...] = () +class SecretReference(_RemoteValue): + """Where a Secret lives, carried as its parts rather than as one string. + + A joined id would need a delimiter, and a delimiter has to be excluded from + every name and segment forever, agreed on by both sides, and re-agreed each + time either grows a new way to be configured. Naming the parts settles all + of that: nothing here is parsed, so nothing can parse two ways. + """ + + name: str + namespace_path: tuple[str, ...] = () + + +class SecretBinding(_RemoteValue): + """How a Secret reaches the Function that binds it. + + One list rather than a field per delivery mode: a binding is the concept, + and how it arrives is a property of one. ``kind`` is open, so a binding a + newer service introduces decodes here instead of failing the whole + FunctionVersion. + """ + + kind: str + variable: Optional[str] = None + secret_ref: Optional[SecretReference] = None + + class FunctionSignature(_RemoteValue): inputs: tuple[FunctionParameter, ...] output: FunctionOutput @@ -239,6 +282,23 @@ class PythonRuntimeSpec(_RemoteValue): python_version: Optional[str] = None environment: Optional[PythonEnvironmentSpec] = None env: Optional[Mapping[str, str]] = None + gpu: Optional[bool] = None + + @model_validator(mode="before") + @classmethod + def _discard_unknown_runtime_payload(cls, value): + if isinstance(value, Mapping): + kind = value.get("kind") + if isinstance(kind, str) and kind not in {"python", "python_v2"}: + return {"kind": kind} + return value + + @field_validator("gpu", mode="before") + @classmethod + def _validate_gpu_marker(cls, value): + if value is None: + return None + return _validate_gpu_wire_marker(value) @model_validator(mode="after") def _validate_runtime_kind(self): @@ -247,28 +307,57 @@ class PythonRuntimeSpec(_RemoteValue): raise ValueError("python runtime requires python_version") if self.environment is None: raise ValueError("python runtime requires environment") + if self.gpu is not None: + raise ValueError("python runtime with gpu requires kind='python_v2'") + elif self.kind == "python_v2": + if self.python_version is None: + raise ValueError("python_v2 runtime requires python_version") + if self.environment is None: + raise ValueError("python_v2 runtime requires environment") + if self.gpu is None: + raise ValueError("python_v2 runtime requires gpu") else: object.__setattr__(self, "python_version", None) object.__setattr__(self, "environment", None) object.__setattr__(self, "env", None) + object.__setattr__(self, "gpu", None) return self -class FunctionVersion(_RemoteValue): - """An exact immutable Function version returned by Enterprise. +class FunctionImage(_RemoteValue): + """A complete OCI Function image identified by its exact manifest digest.""" - Scheduling resources, priority, concurrency, and retry policy belong to - the submitting Job and are not part of this identity. - """ + manifest_digest: str + descriptor: Mapping[str, Any] + source: bool + + +def _validate_object_version(value: str) -> str: + if int(value) > 2**64 - 1: + raise ValueError("Function version exceeds uint64") + return value + + +_ObjectVersion = Annotated[ + str, + Field(strict=True, pattern=r"^[1-9][0-9]*$"), + AfterValidator(_validate_object_version), +] + + +class FunctionVersion(_RemoteValue): + """A pinned object revision, independent of its executable image digest.""" name: str - version: str - artifact: FunctionArtifact + object_id: str + location: str + version: _ObjectVersion + image: FunctionImage signature: FunctionSignature - runtime: PythonRuntimeSpec - runtime_digest: str - environment_digest: str + secret_bindings: tuple[SecretBinding, ...] = () created_at: str + metadata: Mapping[str, str] + disabled: bool def __call__(self, **inputs: Any) -> FunctionApplication: """Bind this exact version to named table columns. @@ -322,24 +411,39 @@ class FunctionVersion(_RemoteValue): ) ) return FunctionApplication( - function=FunctionVersionRef(name=self.name, version=self.version), + function=FunctionVersionRef( + name=self.name, + object_id=self.object_id, + location=self.location, + version=self.version, + manifest_digest=self.image.manifest_digest, + ), inputs=tuple(bindings), output=self.signature.output, ) class FunctionRegistrationRequest(_RemoteValue): - """Stable remote registration envelope produced by :func:`udf`.""" + """Stable remote registration envelope produced by :func:`udf`. + + Credential values deliberately have no field here. The only secret-shaped + thing a client sends is ``secret_bindings``: the name of a Secret the + database already holds, which the remote service resolves at execution. + """ name: str artifact: FunctionArtifactRequest signature: FunctionSignature runtime: PythonRuntimeSpec + secret_bindings: tuple[SecretBinding, ...] = () class FunctionVersionRef(_OpenRemoteValue): name: str - version: str + object_id: str + location: str + version: _ObjectVersion + manifest_digest: str class ApplicationInput(_OpenRemoteValue): @@ -429,11 +533,7 @@ class InputBinding(_RemoteValue): class OutputMapping(_RemoteValue): - """One stable result-field mapping. - - Assignment state is outside the Slice 1 client contract. During the NULL - transition Lance exposes no public cell-flag identifier to persist here. - """ + """One stable result-field mapping.""" result_field: str output_name: str @@ -443,6 +543,13 @@ class OutputMapping(_RemoteValue): nullable: bool +class AssignmentMapping(_RemoteValue): + """Internal physical column preserving flattened struct validity.""" + + output_name: str + output_field_id: _Int32 + + class FunctionBinding(_RemoteValue): """Immutable Function binding persisted by the Enterprise table service.""" @@ -450,6 +557,7 @@ class FunctionBinding(_RemoteValue): function: FunctionVersionRef inputs: tuple[InputBinding, ...] outputs: tuple[OutputMapping, ...] + assignment: Optional[AssignmentMapping] = None input_schema: Optional[Mapping[str, Any]] = None output_schema: Optional[Mapping[str, Any]] = None @@ -480,6 +588,14 @@ class RefreshColumnResult(_RemoteValue): _FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$") +_FUNCTION_BLOB_V2_TYPE = "blob_v2" +_ARROW_EXTENSION_NAME_KEY = "ARROW:extension:name" +_BLOB_V2_EXTENSION_NAME = "lance.blob.v2" +_NESTED_BLOB_COLLECTION_ERROR = ( + "unsupported Arrow type for Function signature: Blob v2 fields nested under " + "collection types are not supported" +) + _GRAMMAR_PRIMITIVES = ( (pa.bool_(), "bool"), @@ -495,6 +611,7 @@ _GRAMMAR_PRIMITIVES = ( (pa.float32(), "float32"), (pa.float64(), "float64"), (pa.string(), "utf8"), + (pa.large_string(), "large_utf8"), (pa.binary(), "binary"), (pa.date32(), "date32"), (pa.date64(), "date64"), @@ -502,31 +619,258 @@ _GRAMMAR_PRIMITIVES = ( def _canonical_arrow_type(data_type: pa.DataType) -> str: - """The server's V1 Function type grammar. Anything outside it is rejected - here rather than at registration.""" + """The compact Function grammar, or canonical exact JSON for nested types.""" + grammar = _grammar_arrow_type(data_type) + if grammar is not None: + return grammar + exact = _exact_arrow_type(data_type) + return json.dumps(exact, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _grammar_arrow_type(data_type: pa.DataType) -> Optional[str]: for candidate, name in _GRAMMAR_PRIMITIVES: if data_type == candidate: return name if pa.types.is_list(data_type) or pa.types.is_large_list(data_type): + item = _grammar_list_item(data_type) + if item is None: + return None prefix = "list" if pa.types.is_list(data_type) else "large_list" - return f"{prefix}<{_canonical_list_item(data_type)}>" + return f"{prefix}<{item}>" if pa.types.is_fixed_size_list(data_type) and data_type.list_size > 0: - return ( - f"fixed_size_list<{_canonical_list_item(data_type)}, {data_type.list_size}>" - ) - raise TypeError(f"unsupported Arrow type for Function signature: {data_type}") + item = _grammar_list_item(data_type) + if item is not None: + return f"fixed_size_list<{item}, {data_type.list_size}>" + return None -def _canonical_list_item(data_type: pa.DataType) -> str: +def _grammar_list_item(data_type: pa.DataType) -> Optional[str]: """The grammar names only the item type; it always means a non-nullable - child called `item`, so any other child metadata cannot be represented.""" + child called `item`, so other child properties require exact JSON.""" child = data_type.value_field if child.name != "item" or child.nullable or child.metadata: + return None + return _grammar_arrow_type(child.type) + + +def _validate_exact_arrow_field(field: pa.Field) -> None: + if not field.name: raise TypeError( - "unsupported Arrow type for Function signature: list items must be a " - f"non-nullable field named 'item', got {child}" + "unsupported Arrow type for Function signature: field names " + "must not be empty" ) - return _canonical_arrow_type(child.type) + if _is_blob_v2_field(field): + if not _has_supported_blob_v2_layout(field): + raise TypeError( + "unsupported Arrow type for Function signature: lance.blob.v2 " + f"requires a supported Blob storage layout, got {field}" + ) + metadata = { + (key.decode() if isinstance(key, bytes) else key): ( + value.decode() if isinstance(value, bytes) else value + ) + for key, value in (field.metadata or {}).items() + } + if metadata and metadata != { + _ARROW_EXTENSION_NAME_KEY: _BLOB_V2_EXTENSION_NAME + }: + raise TypeError( + "unsupported Arrow type for Function signature: lance.blob.v2 " + "field metadata must contain only its canonical extension marker" + ) + elif field.metadata: + raise TypeError( + "unsupported Arrow type for Function signature: field metadata " + f"is not supported, got {field}" + ) + + +def _has_supported_blob_v2_layout(field: pa.Field) -> bool: + data_type = field.type + if isinstance(data_type, pa.ExtensionType): + data_type = data_type.storage_type + if not pa.types.is_struct(data_type): + return False + + fields = tuple(data_type) + + def matches(spec, compare_nullable) -> bool: + return len(fields) == len(spec) and all( + actual.name == name + and actual.type == expected_type + and (not check_nullable or actual.nullable == nullable) + for actual, (name, expected_type, nullable), check_nullable in zip( + fields, spec, compare_nullable + ) + ) + + logical_minimal = ( + ("data", pa.large_binary(), True), + ("uri", pa.utf8(), True), + ) + logical_full = logical_minimal + ( + ("position", pa.uint64(), True), + ("size", pa.uint64(), True), + ) + prepared = ( + ("kind", pa.uint8(), True), + ("data", pa.large_binary(), True), + ("uri", pa.utf8(), True), + ("blob_id", pa.uint32(), True), + ("blob_size", pa.uint64(), True), + ("position", pa.uint64(), True), + ) + descriptor = ( + ("kind", pa.uint8(), False), + ("position", pa.uint64(), False), + ("size", pa.uint64(), False), + ("blob_id", pa.uint32(), False), + ("blob_uri", pa.utf8(), False), + ) + return ( + matches(logical_minimal, (True, True)) + or matches(logical_full, (True, True, False, False)) + or matches(prepared, (True,) * len(prepared)) + or matches(descriptor, (False,) * len(descriptor)) + ) + + +def _canonical_arrow_field(field: pa.Field) -> str: + _validate_exact_arrow_field(field) + if _is_blob_v2_field(field): + return _FUNCTION_BLOB_V2_TYPE + return _canonical_arrow_type(field.type) + + +def _blob_storage_type(field: pa.Field) -> pa.DataType: + data_type = field.type + if isinstance(data_type, pa.ExtensionType): + return data_type.storage_type + return data_type + + +def _exact_blob_storage_type(field: pa.Field) -> dict[str, Any]: + storage = _blob_storage_type(field) + if not pa.types.is_struct(storage): + raise TypeError( + "unsupported Arrow type for Function signature: lance.blob.v2 " + "requires struct storage" + ) + return { + "type": "struct", + "fields": [ + { + "name": child.name, + "nullable": child.nullable, + "type": ( + {"type": "large_binary"} + if pa.types.is_large_binary(child.type) + else _exact_arrow_type(child.type) + ), + } + for child in storage + ], + } + + +def _data_type_has_blob_v2(data_type: pa.DataType) -> bool: + if pa.types.is_struct(data_type): + return any( + _is_blob_v2_field(field) or _data_type_has_blob_v2(field.type) + for field in data_type + ) + if ( + pa.types.is_list(data_type) + or pa.types.is_large_list(data_type) + or pa.types.is_fixed_size_list(data_type) + ): + field = data_type.value_field + return _is_blob_v2_field(field) or _data_type_has_blob_v2(field.type) + if pa.types.is_map(data_type): + return any( + _is_blob_v2_field(field) or _data_type_has_blob_v2(field.type) + for field in (data_type.key_field, data_type.item_field) + ) + return False + + +def _exact_arrow_field( + field: pa.Field, *, inside_collection: bool = False +) -> dict[str, Any]: + _validate_exact_arrow_field(field) + if _is_blob_v2_field(field): + if inside_collection: + raise TypeError(_NESTED_BLOB_COLLECTION_ERROR) + return { + "name": field.name, + "nullable": field.nullable, + "type": _exact_blob_storage_type(field), + "metadata": { + _ARROW_EXTENSION_NAME_KEY: _BLOB_V2_EXTENSION_NAME, + }, + } + value = { + "name": field.name, + "nullable": field.nullable, + "type": _exact_arrow_type(field.type, inside_collection=inside_collection), + } + return value + + +def _exact_arrow_type( + data_type: pa.DataType, *, inside_collection: bool = False +) -> dict[str, Any]: + for candidate, name in _GRAMMAR_PRIMITIVES: + if data_type == candidate: + return {"type": name} + if pa.types.is_struct(data_type): + fields = list(data_type) + names = [field.name for field in fields] + if not fields or len(set(names)) != len(names): + raise TypeError( + "unsupported Arrow type for Function signature: structs must have " + "non-empty, uniquely named fields" + ) + return { + "type": "struct", + "fields": [ + _exact_arrow_field(field, inside_collection=inside_collection) + for field in fields + ], + } + if ( + pa.types.is_list(data_type) + or pa.types.is_large_list(data_type) + or pa.types.is_fixed_size_list(data_type) + ): + if pa.types.is_fixed_size_list(data_type): + if data_type.value_field.name != "item": + raise TypeError( + "unsupported Arrow type for Function signature: fixed-size list " + "items must be named 'item'" + ) + if data_type.list_size <= 0: + raise TypeError( + f"unsupported Arrow type for Function signature: {data_type}" + ) + value: dict[str, Any] = { + "type": ( + "list" + if pa.types.is_list(data_type) + else "large_list" + if pa.types.is_large_list(data_type) + else "fixed_size_list" + ), + "fields": [ + _exact_arrow_field(data_type.value_field, inside_collection=True) + ], + } + if pa.types.is_fixed_size_list(data_type): + value["length"] = data_type.list_size + return value + if pa.types.is_map(data_type) and _data_type_has_blob_v2(data_type): + raise TypeError(_NESTED_BLOB_COLLECTION_ERROR) + raise TypeError(f"unsupported Arrow type for Function signature: {data_type}") def _list_of(item: pa.DataType) -> pa.DataType: @@ -600,8 +944,15 @@ def _callable_parameters(function: Callable[..., Any]) -> tuple[inspect.Paramete def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutput: if isinstance(output, pa.Schema): + if output.metadata: + raise TypeError("Function output schema metadata is not supported") fields = tuple(output) - elif isinstance(output, pa.Field) and pa.types.is_struct(output.type): + elif ( + isinstance(output, pa.Field) + and not _is_blob_v2_field(output) + and pa.types.is_struct(output.type) + ): + _validate_exact_arrow_field(output) if output.nullable: raise ValueError("Function output must be non-nullable") fields = tuple(output.type) @@ -617,18 +968,19 @@ def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutp raise TypeError( "output_schema must be a PyArrow DataType, Field, or Schema" ) + _validate_exact_arrow_field(field) if field.nullable: raise ValueError("Function output must be non-nullable") return FunctionOutput( kind="scalar", - arrow_type=_canonical_arrow_type(field.type), + arrow_type=_canonical_arrow_field(field), nullable=False, ) if not fields: raise ValueError("named-struct Function output must contain at least one field") - if any(field.nullable for field in fields): - raise ValueError("Function output fields must be non-nullable") + for field in fields: + _validate_exact_arrow_field(field) names = [field.name for field in fields] if len(set(names)) != len(names): raise ValueError("Function output field names must be unique") @@ -637,8 +989,8 @@ def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutp fields=tuple( FunctionResultField( name=field.name, - arrow_type=_canonical_arrow_type(field.type), - nullable=False, + arrow_type=_canonical_arrow_field(field), + nullable=field.nullable, ) for field in fields ), @@ -657,6 +1009,10 @@ def _infer_signature( if input_schema is not None: if not isinstance(input_schema, pa.Schema): raise TypeError("input_schema must be a PyArrow Schema") + if input_schema.metadata: + raise TypeError("Function input schema metadata is not supported") + for field in input_schema: + _validate_exact_arrow_field(field) expected = tuple(parameter.name for parameter in parameters) actual = tuple(input_schema.names) if actual != expected: @@ -667,7 +1023,7 @@ def _infer_signature( inputs = tuple( FunctionParameter( name=field.name, - arrow_type=_canonical_arrow_type(field.type), + arrow_type=_canonical_arrow_field(field), nullable=field.nullable, ) for field in input_schema @@ -690,7 +1046,9 @@ def _infer_signature( inputs.append( FunctionParameter( name=parameter.name, - arrow_type=_canonical_arrow_type(data_type), + arrow_type=_canonical_arrow_field( + pa.field(parameter.name, data_type, nullable=nullable) + ), nullable=nullable, ) ) @@ -910,6 +1268,7 @@ class UdfDefinition: pip: tuple[str, ...], env: Mapping[str, str], python_version: Optional[str], + gpu: bool = False, conda: tuple[str, ...] = (), conda_channels: tuple[str, ...] = (), ): @@ -938,12 +1297,14 @@ class UdfDefinition: signature = _infer_signature(function, input_schema, output_schema) source = _package_source(function) digest = f"sha256:{hashlib.sha256(source).hexdigest()}" + gpu_marker = _normalize_gpu_marker(gpu) runtime = PythonRuntimeSpec( - kind="python", + kind="python_v2" if gpu_marker is not None else "python", python_version=python_version or f"{sys.version_info.major}.{sys.version_info.minor}", environment=environment_spec, env=environment, + gpu=gpu_marker, ) self._function = function self._request = FunctionRegistrationRequest( @@ -968,9 +1329,73 @@ class UdfDefinition: @property def registration_request(self) -> FunctionRegistrationRequest: - """The immutable request sent by ``create_function_async``.""" + """The immutable request sent by ``create_function_async``. + + Carries no secret bindings. Binding is a registration-time decision, + so a Function bound to Secrets is registered through :meth:`bind_secrets`, + which is what ``create_function`` calls. + """ return self._request + def bind_secrets( + self, secrets: Optional[Sequence[EnvVarSecret]] + ) -> FunctionRegistrationRequest: + """The registration request for this definition bound to ``secrets``. + + Binding does not change the Function's source: each + [EnvVarSecret][lancedb.secrets.EnvVarSecret] names a Secret and the + environment variable its value should arrive in, and the Function reads + that variable the way it already did. Whether the named Secrets exist is + the server's answer, not this one. + """ + bindings = () if secrets is None else tuple(secrets) + wrong_type = [ + binding for binding in bindings if not isinstance(binding, EnvVarSecret) + ] + if wrong_type: + kinds = sorted({type(binding).__name__ for binding in wrong_type}) + raise TypeError( + f"Function secrets must be EnvVarSecret values, not {kinds!r}; a " + "credential value is never sent to this API" + ) + variables = [binding.env_variable for binding in bindings] + duplicates = sorted({name for name in variables if variables.count(name) > 1}) + if duplicates: + raise ValueError( + "a Function binds each environment variable once; duplicated: " + f"{duplicates!r}" + ) + # `env` is ordinary configuration carried in the definition, so a name in + # both would have a value visible in the Function's record and a value + # that is not. Refuse rather than pick. + environment = self._request.runtime.env or {} + overlap = sorted(set(environment) & set(variables)) + if overlap: + raise ValueError( + f"Function env and secret bindings must be disjoint: {overlap!r}" + ) + if not bindings: + return self._request + # Sorted, because the list is carried in the FunctionVersion hash and a + # caller's argument order is not part of what a Function is. + resolved = tuple( + sorted( + ( + SecretBinding( + kind="env", + variable=binding.env_variable, + secret_ref=SecretReference( + name=binding.secret_name, + namespace_path=tuple(binding.secret_namespace_path), + ), + ) + for binding in bindings + ), + key=lambda binding: (binding.kind, binding.variable or ""), + ) + ) + return self._request._copy(update={"secret_bindings": resolved}) + def __call__(self, *args, **kwargs): return self._function(*args, **kwargs) @@ -989,6 +1414,7 @@ def udf( pip: tuple[str, ...] | list[str] = (), env: Optional[Mapping[str, str]] = None, python_version: Optional[str] = None, + gpu: bool = False, conda: tuple[str, ...] | list[str] = (), conda_channels: tuple[str, ...] | list[str] = (), ) -> Callable[[Callable[..., Any]], UdfDefinition]: ... @@ -1003,6 +1429,7 @@ def udf( pip: tuple[str, ...] | list[str] = (), env: Optional[Mapping[str, str]] = None, python_version: Optional[str] = None, + gpu: bool = False, conda: tuple[str, ...] | list[str] = (), conda_channels: tuple[str, ...] | list[str] = (), ): @@ -1010,8 +1437,9 @@ def udf( Input and output signatures are inferred from supported annotations. For Arrow types annotations cannot express precisely, pass ``input_schema`` - and ``output_schema`` together. Nullable outputs are rejected because V1 - uses physical NULL to represent unassigned computed-column rows. + and ``output_schema`` together. Scalar outputs must be non-nullable. Every + named-struct field may be nullable; Enterprise preserves the struct's + validity when the result is expanded into sibling columns. Parameters ---------- @@ -1023,8 +1451,8 @@ def udf( Explicit input fields in the exact order of the callable parameters. Must be provided together with ``output_schema``. output_schema : pyarrow.DataType, pyarrow.Field, or pyarrow.Schema, optional - Explicit scalar or named-struct output. Must be non-nullable and be - provided together with ``input_schema``. + Explicit scalar or named-struct output. Scalar outputs must be + non-nullable. Must be provided together with ``input_schema``. pip : sequence of str, optional Pip requirements for the remote environment. conda : sequence of str, optional @@ -1032,9 +1460,15 @@ def udf( conda_channels : sequence of str, optional Conda channels in priority order; requires ``conda``. env : mapping of str to str, optional - Environment variables included in the Function definition. + Environment variables included in the Function definition. Not for + credentials -- these are ordinary configuration, stored with the + Function and visible wherever it is. python_version : str, optional Remote Python major/minor version. Defaults to the client version. + gpu : bool, default False + Whether every remote execution requires a GPU. The execution platform + selects one compatible GPU for each worker. The requirement is part of + the immutable Function version. The packaged artifact is a snapshot: the function source plus exactly the module-level names it references (modules as imports, importable @@ -1059,6 +1493,11 @@ def udf( ... return value * 2 >>> score(1.5) 3.0 + >>> @udf(pip=["cupy-cuda12x"], gpu=True) + ... def gpu_score(value: int) -> int: + ... return value * 2 + >>> gpu_score.registration_request.runtime.gpu + True """ def decorate(target: Callable[..., Any]) -> UdfDefinition: @@ -1070,6 +1509,7 @@ def udf( pip=tuple(pip), env={} if env is None else env, python_version=python_version, + gpu=gpu, conda=tuple(conda), conda_channels=tuple(conda_channels), ) @@ -1080,6 +1520,7 @@ def udf( __all__ = [ + "AssignmentMapping", "ApplicationInput", "FunctionApplication", "FunctionArtifact", @@ -1091,6 +1532,7 @@ __all__ = [ "FunctionRegistrationRequest", "FunctionResultField", "FunctionSignature", + "FunctionImage", "FunctionVersion", "FunctionVersionRef", "InputBinding", diff --git a/python/python/lancedb/index.py b/python/python/lancedb/index.py index 948342887..78ee72e57 100644 --- a/python/python/lancedb/index.py +++ b/python/python/lancedb/index.py @@ -751,7 +751,7 @@ class IvfPq: This value controls how much the vector is compressed during the quantization step. The more sub vectors there are the less the vector is compressed. The default is the dimension of the vector divided by 16. If - the dimension is not evenly divisible by 16 we use the dimension divded by + the dimension is not evenly divisible by 16 we use the dimension divided by 8. The above two cases are highly preferred. Having 8 or 16 values per diff --git a/python/python/lancedb/job.py b/python/python/lancedb/job.py index f688768cb..e57fde0ea 100644 --- a/python/python/lancedb/job.py +++ b/python/python/lancedb/job.py @@ -4,15 +4,27 @@ """Handles to operations a server may run asynchronously.""" import asyncio +import json from datetime import timedelta from typing import Any, Callable, Generic, Optional, TypeVar, cast +import pyarrow as pa + from lancedb.background_loop import LOOP from . import _lancedb +from ._lancedb import JobDescription, JobFailureInfo, JobInfo T = TypeVar("T") +__all__ = [ + "AsyncJob", + "Job", + "JobDescription", + "JobFailureInfo", + "JobInfo", +] + class AsyncJob(Generic[T]): """A handle to an operation that may still be running. @@ -78,6 +90,149 @@ class AsyncJob(Generic[T]): return await self._inner.cancel() + async def refresh(self) -> None: + """Ask the backend for this job's current state, and for a server-side + job its full record, then cache it for the properties below. + + The properties are all `None` until this runs, because submitting an + operation returns only a job id. `status` fetches the whole record too; + `wait` records only the terminal state it establishes. + """ + if self._inner is None: + return + await self._inner.refresh() + + @property + def state(self) -> Optional[str]: + """The last observed lifecycle state, without contacting the backend. + + `None` until the handle has talked to it. See :meth:`AsyncJob.refresh`. + """ + if self._inner is None: + return "finished" + return self._inner._state + + @property + def job_type(self) -> Optional[str]: + """The job's type, as the server names it. + + `None` for an in-process job, which has no server-side record. + """ + return self._field("job_type") + + @property + def creation_ms(self) -> Optional[int]: + """When the job was created, in milliseconds since the epoch.""" + return self._field("creation_ms") + + @property + def spec(self) -> Optional[Any]: + """The job-type-specific specification it was submitted with.""" + return self._field("spec") + + @property + def result(self) -> Optional[Any]: + """The job-type-specific terminal result, as reported data rather than + the typed model :meth:`AsyncJob.wait` returns. + + `None` until the job succeeds, so a job that never terminates reports + its progress through :meth:`AsyncJob.events` instead. + """ + return self._field("result") + + @property + def failure(self) -> Optional[JobFailureInfo]: + """Why the job failed, when it failed and the server reports a reason.""" + return self._field("failure") + + @property + def _spec_json(self) -> Optional[str]: + return self._field("_spec_json") + + @property + def _result_json(self) -> Optional[str]: + return self._field("_result_json") + + def _field(self, name: str) -> Optional[Any]: + description = self._inner._description if self._inner is not None else None + return getattr(description, name) if description is not None else None + + async def events( + self, + *, + limit: Optional[int] = None, + filter: Optional[str] = None, + ) -> "pa.Table": + """This job's recorded lifecycle events. + + Where the properties above report a terminal result only once the job + reaches one, events are written as the job runs and outlive the workers + that produced them. A distributed job records a `claim`/`claim_complete` + pair per unit of work, each carrying `rows_processed`, so a job that + never finishes still accounts for what it did. + + Parameters + ---------- + limit: int, optional + Maximum event rows to return. The server caps results at 1000 by + default and 10,000 at most, and truncates without saying so, so + pass this for a job that emits an event per fragment. + filter: str, optional + SQL-like expression over the `state`, `updated_by`, `emitted_from`, + `emitted_by`, and `claim_entity` columns, such as + ``state = 'claim_complete'``. + """ + if self._inner is None: + raise NotImplementedError( + "job event history is only available for server-side jobs" + ) + return await self._inner.events(limit=limit, filter=filter) + + def __repr__(self) -> str: + return _job_repr("AsyncJob", self) + + +_REPR_INDENT = " " * 4 + + +def _repr_payload(value: Any) -> str: + """Render a job payload as indented JSON, aligned under its field.""" + try: + rendered = json.dumps(value, indent=4) + except TypeError: + return repr(value) + return rendered.replace("\n", "\n" + _REPR_INDENT) + + +def _job_repr(kind: str, job: Any) -> str: + """Render every field the handle currently knows, omitting the rest. + + One field per line, with the JSON payloads indented, because a refresh + job's spec and result are the point of printing it. + """ + state = job.state + if state is None: + # Nothing has been fetched yet, so there is nothing to lay out. + known = f"id={job.id!r}, " if job.id is not None else "" + return f"{kind}({known}not refreshed)" + + fields = [] + if job.id is not None: + fields.append(f"id={job.id!r}") + fields.append(f"state={state!r}") + for name in ("job_type", "creation_ms"): + value = getattr(job, name) + if value is not None: + fields.append(f"{name}={value!r}") + for name in ("spec", "result"): + value = getattr(job, name) + if value is not None: + fields.append(f"{name}={_repr_payload(value)}") + if job.failure is not None: + fields.append(f"failure={job.failure!r}") + body = "".join(f"\n{_REPR_INDENT}{field}," for field in fields) + return f"{kind}({body}\n)" + class Job(Generic[T]): """Synchronous counterpart of `AsyncJob` with the same result type.""" @@ -122,6 +277,75 @@ class Job(Generic[T]): return LOOP.run(self._inner.cancel()) + def refresh(self) -> None: + """Ask the backend for this job's current state and record. + + See :meth:`AsyncJob.refresh`. + """ + if self._inner is None: + return + LOOP.run(self._inner.refresh()) + + @property + def state(self) -> Optional[str]: + """The last observed lifecycle state. See :attr:`AsyncJob.state`.""" + return self._inner.state if self._inner is not None else "finished" + + @property + def job_type(self) -> Optional[str]: + """The job's type. See :attr:`AsyncJob.job_type`.""" + return self._field("job_type") + + @property + def creation_ms(self) -> Optional[int]: + """When the job was created. See :attr:`AsyncJob.creation_ms`.""" + return self._field("creation_ms") + + @property + def spec(self) -> Optional[Any]: + """The job's specification. See :attr:`AsyncJob.spec`.""" + return self._field("spec") + + @property + def result(self) -> Optional[Any]: + """The job's terminal result. See :attr:`AsyncJob.result`.""" + return self._field("result") + + @property + def failure(self) -> Optional[JobFailureInfo]: + """Why the job failed. See :attr:`AsyncJob.failure`.""" + return self._field("failure") + + @property + def _spec_json(self) -> Optional[str]: + return self._field("_spec_json") + + @property + def _result_json(self) -> Optional[str]: + return self._field("_result_json") + + def _field(self, name: str) -> Optional[Any]: + return getattr(self._inner, name) if self._inner is not None else None + + def events( + self, + *, + limit: Optional[int] = None, + filter: Optional[str] = None, + ) -> "pa.Table": + """This job's recorded lifecycle events. + + See :meth:`AsyncJob.events`. + """ + if self._inner is None: + raise NotImplementedError( + "job event history is only available for server-side jobs" + ) + return LOOP.run(self._inner.events(limit=limit, filter=filter)) + + def __repr__(self) -> str: + return _job_repr("Job", self) + def _typed_job( inner: "_lancedb.Job", result_decoder: Callable[[str], T] diff --git a/python/python/lancedb/materialized_view.py b/python/python/lancedb/materialized_view.py index 5abb44dc0..384683c9f 100644 --- a/python/python/lancedb/materialized_view.py +++ b/python/python/lancedb/materialized_view.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union from .background_loop import LOOP +from .job import AsyncJob, Job, _typed_job if TYPE_CHECKING: import pyarrow as pa @@ -42,6 +43,8 @@ class MaterializedViewDefinition: """Cap on the number of rows the view holds.""" inputs: List[str] = field(default_factory=list) """Source columns the projections and filter read.""" + source_namespace: List[str] = field(default_factory=list) + """Namespace holding the source table; empty is the root namespace.""" def _definition_from_schema( @@ -53,7 +56,8 @@ def _definition_from_schema( raise ValueError(f"Table '{name}' is not a materialized view") value = json.loads(raw) kind = value.get("kind") - if kind != "select": + # "namespaced_select" keeps older readers from resolving the source at root. + if kind not in ("select", "namespaced_select"): raise NotImplementedError( f"materialized view '{name}' is defined by '{kind}', which this " "version of lancedb cannot refresh" @@ -66,6 +70,21 @@ def _definition_from_schema( filter=value.get("filter"), limit=value.get("limit"), inputs=value.get("inputs", []), + source_namespace=value.get("source_namespace", []), + ) + + +def _definition_from_json(raw: str) -> MaterializedViewDefinition: + value = json.loads(raw) + return MaterializedViewDefinition( + source_table=value["source_table"], + projections=[ + (p["output"], p["expression"]) for p in value.get("projections", []) + ], + filter=value.get("filter"), + limit=value.get("limit"), + inputs=value.get("inputs", []), + source_namespace=value.get("source_namespace", []), ) @@ -122,8 +141,9 @@ class AsyncMaterializedView: return self._table async def definition(self) -> MaterializedViewDefinition: - """The query that defines the view, read from its stored schema.""" - return _definition_from_schema(await self._table.schema(), self.name) + """The query that defines the view.""" + raw = await self._table._inner.materialized_view_definition() + return _definition_from_json(raw) async def refresh( self, *, full: bool = False, source_version: Optional[int] = None @@ -144,6 +164,24 @@ class AsyncMaterializedView: full=full, source_version=source_version ) + async def refresh_async( + self, *, full: bool = False, source_version: Optional[int] = None + ) -> "AsyncJob[RefreshMaterializedViewResult]": + """Submit a refresh and return its job without waiting. + + The job may already be complete for a local view. On LanceDB Cloud + and Enterprise, its ``id`` is the server job identifier returned by + the refresh endpoint. + """ + from ._lancedb import RefreshMaterializedViewResult + + return _typed_job( + await self._table._inner.refresh_materialized_view_async( + full=full, source_version=source_version + ), + RefreshMaterializedViewResult.from_json, + ) + class MaterializedView: """Synchronous variant of @@ -167,8 +205,8 @@ class MaterializedView: @property def definition(self) -> MaterializedViewDefinition: - """The query that defines the view, read from its stored schema.""" - return _definition_from_schema(self._table.schema, self.name) + """The query that defines the view.""" + return LOOP.run(self._async.definition()) def refresh( self, *, full: bool = False, source_version: Optional[int] = None @@ -176,3 +214,17 @@ class MaterializedView: """Recompute the view from its source. See [AsyncMaterializedView.refresh][lancedb.materialized_view.AsyncMaterializedView.refresh].""" return LOOP.run(self._async.refresh(full=full, source_version=source_version)) + + def refresh_async( + self, *, full: bool = False, source_version: Optional[int] = None + ) -> "Job[RefreshMaterializedViewResult]": + """Submit a refresh and return its job without waiting. + + See + [AsyncMaterializedView.refresh_async][lancedb.materialized_view.AsyncMaterializedView.refresh_async]. + """ + return Job( + LOOP.run( + self._async.refresh_async(full=full, source_version=source_version) + ) + ) diff --git a/python/python/lancedb/namespace.py b/python/python/lancedb/namespace.py index f2e553321..fe38a47c7 100644 --- a/python/python/lancedb/namespace.py +++ b/python/python/lancedb/namespace.py @@ -12,6 +12,7 @@ from __future__ import annotations import sys from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union +from uuid import UUID if sys.version_info >= (3, 12): from typing import override @@ -48,8 +49,11 @@ from lancedb._lancedb import ( connect_namespace_client as _connect_namespace_client, ) from lancedb.background_loop import LOOP +from lancedb.arrow import AsyncRecordBatchReader from lancedb.db import AsyncConnection, DBConnection from lancedb.job import AsyncJob, Job +from lancedb.sql import AsyncQuery as AsyncSqlQuery +from lancedb.sql import QueryDescription from lance_namespace import ( LanceNamespace, connect as namespace_connect, @@ -633,6 +637,7 @@ class LanceNamespaceDBConnection(DBConnection): select: "SelectArg" = None, where: Optional[str] = None, limit: Optional[int] = None, + with_no_data: bool = False, ) -> "MaterializedView": """Define a materialized view over a table in the root namespace. See @@ -642,12 +647,40 @@ class LanceNamespaceDBConnection(DBConnection): self.open_table( LOOP.run( self._inner.create_materialized_view( - name, source, select=select, where=where, limit=limit + name, + source, + select=select, + where=where, + limit=limit, + with_no_data=with_no_data, ) ).name ) ) + @override + def create_materialized_view_async( + self, + name: str, + source: str, + *, + select: "SelectArg" = None, + where: Optional[str] = None, + limit: Optional[int] = None, + with_no_data: bool = False, + ) -> Job[None]: + job = LOOP.run( + self._inner.create_materialized_view_async( + name, + source, + select=select, + where=where, + limit=limit, + with_no_data=with_no_data, + ) + ) + return Job(job) + @override def open_materialized_view(self, name: str) -> "MaterializedView": """Open the materialized view named ``name``.""" @@ -660,6 +693,30 @@ class LanceNamespaceDBConnection(DBConnection): """The names of the materialized views in the root namespace.""" return LOOP.run(self._inner.list_materialized_views()) + @override + def drop_materialized_view( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> None: + if namespace_path is None: + namespace_path = [] + LOOP.run( + self._inner.drop_materialized_view(name, namespace_path=namespace_path) + ) + + @override + def drop_materialized_view_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job[None]: + if namespace_path is None: + namespace_path = [] + return Job( + LOOP.run( + self._inner.drop_materialized_view_async( + name, namespace_path=namespace_path + ) + ) + ) + @override def drop_table(self, name: str, namespace_path: Optional[List[str]] = None): if namespace_path is None: @@ -1190,15 +1247,41 @@ class AsyncLanceNamespaceDBConnection: select: "SelectArg" = None, where: Optional[str] = None, limit: Optional[int] = None, + with_no_data: bool = False, ) -> "AsyncMaterializedView": """Define a materialized view over a table in the root namespace.""" view = await self._inner.create_materialized_view( - name, source, select=select, where=where, limit=limit + name, + source, + select=select, + where=where, + limit=limit, + with_no_data=with_no_data, ) # Reopen through the namespace so the view's table carries the # namespace client and pushdown configuration a bare inner table lacks. return AsyncMaterializedView(await self.open_table(view.name)) + async def create_materialized_view_async( + self, + name: str, + source: str, + *, + select: "SelectArg" = None, + where: Optional[str] = None, + limit: Optional[int] = None, + with_no_data: bool = False, + ) -> AsyncJob[None]: + """Submit materialized-view creation and return its job.""" + return await self._inner.create_materialized_view_async( + name, + source, + select=select, + where=where, + limit=limit, + with_no_data=with_no_data, + ) + async def open_materialized_view(self, name: str) -> "AsyncMaterializedView": """Open the materialized view named ``name``.""" view = AsyncMaterializedView(await self.open_table(name)) @@ -1209,6 +1292,24 @@ class AsyncLanceNamespaceDBConnection: """The names of the materialized views in the root namespace.""" return await self._inner.list_materialized_views() + async def drop_materialized_view( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> None: + """Drop a materialized view from the namespace.""" + if namespace_path is None: + namespace_path = [] + await self._inner.drop_materialized_view(name, namespace_path=namespace_path) + + async def drop_materialized_view_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> AsyncJob[None]: + """Start dropping a materialized view and return its cleanup job.""" + if namespace_path is None: + namespace_path = [] + return await self._inner.drop_materialized_view_async( + name, namespace_path=namespace_path + ) + async def drop_table(self, name: str, namespace_path: Optional[List[str]] = None): """Drop a table from the namespace.""" if namespace_path is None: @@ -1447,6 +1548,37 @@ class AsyncLanceNamespaceDBConnection: namespace_path=namespace_path, page_token=page_token, limit=limit ) + async def execute_query( + self, + query: str, + *, + default_namespace_path: Optional[List[str]] = None, + ) -> AsyncRecordBatchReader: + """Execute SQL when supported by the underlying connection.""" + return await self._inner.execute_query( + query, + default_namespace_path=default_namespace_path, + ) + + async def execute_query_async( + self, + query: str, + *, + default_namespace_path: Optional[List[str]] = None, + ) -> AsyncSqlQuery: + """Start executing SQL when supported by the underlying connection. + + Namespace-backed local connections do not support SQL. + """ + return await self._inner.execute_query_async( + query, + default_namespace_path=default_namespace_path, + ) + + async def describe_query(self, query_id: UUID) -> QueryDescription: + """Describe a submitted SQL query when supported.""" + return await self._inner.describe_query(query_id) + async def namespace_client(self) -> LanceNamespace: """Get the namespace client for this connection. diff --git a/python/python/lancedb/query.py b/python/python/lancedb/query.py index 451384ad1..cef89e63c 100644 --- a/python/python/lancedb/query.py +++ b/python/python/lancedb/query.py @@ -78,6 +78,10 @@ if TYPE_CHECKING: T = TypeVar("T", bound="LanceModel") AnalyzePlanDistributedMetrics = Literal["aggregate", "per_worker", "full"] +# Number of rows a hybrid query returns when no limit was set on it. This +# mirrors the default the Rust query builder applies to its sub-queries. +DEFAULT_HYBRID_LIMIT = 10 + @runtime_checkable class _LanceScanner(Protocol): @@ -109,6 +113,7 @@ def _query_is_plain_scan(query: Query) -> bool: return ( query.vector is None and query.full_text_query is None + and query.take_offsets is None and not query.postfilter and not query.order_by ) @@ -804,6 +809,10 @@ class Query(pydantic.BaseModel): # offset to start fetching results from offset: Optional[int] = None + # Dataset offsets whose duplicate occurrences must be restored after lookup. + # This is populated when a take query is converted to this serializable form. + take_offsets: Optional[List[int]] = None + # if true, will only search the indexed data fast_search: Optional[bool] = None @@ -825,6 +834,7 @@ class Query(pydantic.BaseModel): query = cls() query.limit = req.limit query.offset = req.offset + query.take_offsets = req.take_offsets query.filter = req.filter query.full_text_query = req.full_text_search query.columns = req.select @@ -853,7 +863,7 @@ class Query(pydantic.BaseModel): return query # This tells pydantic to allow custom types (needed for the `vector` query since - # pa.Array wouln't be allowed otherwise) + # pa.Array wouldn't be allowed otherwise) model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) @@ -3887,14 +3897,54 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase): return self + def _create_child_queries( + self, + ) -> Tuple["AsyncFTSQuery", "AsyncVectorQuery", int, int]: + """Build the sub-queries that make up this hybrid query. + + Execution, `explain_plan` and `analyze_plan` all go through here so that + the plans that are reported are the plans that actually run. + + Returns the two sub-queries along with the effective limit and offset of + the hybrid query itself. + """ + fts_query = AsyncFTSQuery(self._inner.to_fts_query(), self._table) + vec_query = AsyncVectorQuery(self._inner.to_vector_query(), self._table) + + fts_req = fts_query._inner.to_query_request() + vec_req = vec_query._inner.to_query_request() + + # Only one of the two sub-queries carries the limit when it was never + # set explicitly: nearest_to()/nearest_to_text() build the sibling query + # from scratch, and that is where the default gets filled in. Which one + # that is depends on the order the hybrid query was built in, so look at + # both rather than at a single side. + limit = fts_req.limit if fts_req.limit is not None else vec_req.limit + if limit is None: + limit = DEFAULT_HYBRID_LIMIT + offset = fts_req.offset or vec_req.offset or 0 + + fts_query.with_row_id() + vec_query.with_row_id() + + # offset() pushes the offset down into both sub-queries, which would make + # each of them skip its own first `offset` rows. The window has to be + # taken out of the combined, reranked results instead, so fetch the + # skipped prefix here too and slice it off afterwards. + fts_query.limit(limit + offset) + vec_query.limit(limit + offset) + fts_query.offset(0) + vec_query.offset(0) + + return fts_query, vec_query, limit, offset + async def to_batches( self, *, max_batch_length: Optional[int] = None, timeout: Optional[timedelta] = None, ) -> AsyncRecordBatchReader: - fts_query = AsyncFTSQuery(self._inner.to_fts_query(), self._table) - vec_query = AsyncVectorQuery(self._inner.to_vector_query(), self._table) + fts_query, vec_query, limit, offset = self._create_child_queries() req = fts_query._inner.to_query_request() blob_auto_row_id = False @@ -3914,9 +3964,6 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase): self._blob_auto_row_id = blob_auto_row_id self._blob_paths = blob_paths - fts_query.with_row_id() - vec_query.with_row_id() - fts_results, vector_results = await asyncio.gather( fts_query.to_arrow(timeout=timeout), vec_query.to_arrow(timeout=timeout), @@ -3928,8 +3975,9 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase): norm=self._norm, fts_query=fts_query.get_query(), reranker=self._reranker, - limit=self._inner.get_limit(), + limit=limit, with_row_ids=True, + offset=offset, ) if ( not self._user_requested_row_id() @@ -3958,14 +4006,14 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase): ... print(plan) >>> asyncio.run(doctest_example()) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE RRFReranker(K=60) - ProjectionExec: expr=[vector@0 as vector, text@3 as text, _distance@2 as _distance] + ProjectionExec: expr=[vector@0 as vector, text@3 as text, _distance@2 as _distance, _rowid@1 as _rowid] LanceRead: uri=..., projection=[text], source=stream(_rowid) GlobalLimitExec: skip=0, fetch=10 FilterExec: _distance@2 IS NOT NULL SortExec: TopK(fetch=10), expr=[_distance@2 ASC NULLS LAST, _rowid@1 ASC NULLS LAST], preserve_partitioning=[false] KNNVectorDistance: metric=l2 LanceRead: uri=..., projection=[vector], ... - ProjectionExec: expr=[vector@2 as vector, text@3 as text, _score@1 as _score] + ProjectionExec: expr=[vector@2 as vector, text@3 as text, _score@1 as _score, _rowid@0 as _rowid] LanceRead: uri=..., projection=[vector, text], source=stream(_rowid) GlobalLimitExec: skip=0, fetch=10 MatchQuery: column=text, query=[hello] @@ -3980,8 +4028,9 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase): plan : str """ # noqa: E501 - vector_plan = await self._inner.to_vector_query().explain_plan(verbose) - fts_plan = await self._inner.to_fts_query().explain_plan(verbose) + fts_query, vec_query, _, _ = self._create_child_queries() + vector_plan = await vec_query.explain_plan(verbose) + fts_plan = await fts_query.explain_plan(verbose) # Indent sub-plans under the reranker indented_vector = "\n".join(" " + line for line in vector_plan.splitlines()) indented_fts = "\n".join(" " + line for line in fts_plan.splitlines()) @@ -4008,14 +4057,12 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase): ------- plan : str """ + fts_query, vec_query, _, _ = self._create_child_queries() + results = ["Vector Search Query:"] - results.append( - await self._inner.to_vector_query().analyze_plan(distributed_metrics) - ) + results.append(await vec_query.analyze_plan(distributed_metrics)) results.append("FTS Search Query:") - results.append( - await self._inner.to_fts_query().analyze_plan(distributed_metrics) - ) + results.append(await fts_query.analyze_plan(distributed_metrics)) return "\n".join(results) diff --git a/python/python/lancedb/remote/__init__.py b/python/python/lancedb/remote/__init__.py index 1f255b991..dbcaa1b64 100644 --- a/python/python/lancedb/remote/__init__.py +++ b/python/python/lancedb/remote/__init__.py @@ -9,7 +9,13 @@ from typing import List, Optional from lancedb import __version__ from .header import HeaderProvider -from .oauth import OAuthConfig, OAuthFlowType +from .oauth import ( + ClientAuthMethod, + OAuthConfig, + OAuthFlowType, + OAuthSession, + TokenCacheOptions, +) # The API reference renders this module with a single mkdocstrings directive, # which only picks up names listed here. New public names must be added to this @@ -22,6 +28,9 @@ __all__ = [ "HeaderProvider", "OAuthConfig", "OAuthFlowType", + "ClientAuthMethod", + "OAuthSession", + "TokenCacheOptions", ] @@ -164,7 +173,10 @@ class ClientConfig: extra_headers: Optional[dict] Additional headers to include in requests. id_delimiter: Optional[str] - The delimiter to use when constructing object identifiers. + The delimiter joining a namespace path and a name into one object + identifier. ``"$"`` is the only supported value, and leaving this + unset is how to get it; anything else is rejected when the connection + is created. tls_config: Optional[TlsConfig] TLS/mTLS configuration for secure connections. header_provider: Optional[HeaderProvider] diff --git a/python/python/lancedb/remote/db.py b/python/python/lancedb/remote/db.py index b228cfb5b..c9e857495 100644 --- a/python/python/lancedb/remote/db.py +++ b/python/python/lancedb/remote/db.py @@ -2,13 +2,24 @@ # SPDX-FileCopyrightText: Copyright The LanceDB Authors +from dataclasses import replace from datetime import timedelta import json import logging from concurrent.futures import ThreadPoolExecutor import sys -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterable, + List, + Optional, + Sequence, + Union, +) from urllib.parse import urlparse +from uuid import UUID import warnings if sys.version_info >= (3, 12): @@ -25,10 +36,13 @@ from ..common import DATA from ..db import DBConnection, LOOP from ..functions import FunctionVersion, UdfDefinition from ..job import AsyncJob, Job +from ..sql import Query as SqlQuery +from ..sql import QueryDescription from ..materialized_view import MaterializedView, SelectArg +from ..secrets import EnvVarSecret, SecretInfo if TYPE_CHECKING: - from .._lancedb import JobDescription, JobInfo + from .._lancedb import JobInfo from ..embeddings import EmbeddingFunctionConfig from lance_namespace import ( LanceNamespace, @@ -116,6 +130,7 @@ class RemoteDBConnection(DBConnection): read_timeout: Optional[float] = None, storage_options: Optional[Dict[str, str]] = None, read_consistency_interval: Optional[timedelta] = None, + sql_host_override: Optional[str] = None, ): """Connect to a remote LanceDB database.""" if isinstance(client_config, dict): @@ -161,6 +176,7 @@ class RemoteDBConnection(DBConnection): self.api_key = api_key self.region = region self.host_override = host_override + self.sql_host_override = sql_host_override self.storage_options = storage_options self.db_name = parsed.netloc @@ -175,17 +191,58 @@ class RemoteDBConnection(DBConnection): api_key=api_key, region=region, host_override=host_override, + sql_host_override=sql_host_override, client_config=client_config, storage_options=storage_options, read_consistency_interval=read_consistency_interval, ) ) + @classmethod + def _from_catalog( + cls, + inner, + name, + endpoint, + api_key, + client_config, + oauth_config, + sql_host_override, + ): + config = ( + ClientConfig(**client_config) + if isinstance(client_config, dict) + else (client_config or ClientConfig()) + ) + headers = { + key: value + for key, value in (config.extra_headers or {}).items() + if key.lower() not in ("x-lancedb-database", "x-lancedb-database-prefix") + } + headers["x-lancedb-database"] = name + result = cls.__new__(cls) + result.db_url = inner.uri + result.db_name = name + result.api_key = api_key or "" + result.region = "us-east-1" + result.host_override = endpoint + result.sql_host_override = sql_host_override + result.storage_options = None + result.client_config = replace(config, extra_headers=headers) + result._catalog_oauth = oauth_config is not None + result._conn = inner + return result + def __repr__(self) -> str: return f"RemoteConnect(name={self.db_name})" @override def serialize(self) -> str: + if getattr(self, "_catalog_oauth", False): + raise ValueError( + "Cannot serialize a catalog connection using OAuth; " + "provide a worker-side connection factory" + ) return json.dumps( { "connection_type": "remote", @@ -193,6 +250,7 @@ class RemoteDBConnection(DBConnection): "api_key": self.api_key, "region": self.region, "host_override": self.host_override, + "sql_host_override": self.sql_host_override, "client_config": _client_config_to_dict(self.client_config), "storage_options": self.storage_options, } @@ -658,22 +716,80 @@ class RemoteDBConnection(DBConnection): select: SelectArg = None, where: Optional[str] = None, limit: Optional[int] = None, + with_no_data: bool = False, ) -> MaterializedView: - raise NotImplementedError( - "materialized views are supported only on local databases" + from .table import RemoteTable + + view = LOOP.run( + self._conn.create_materialized_view( + name, + source, + select=select, + where=where, + limit=limit, + with_no_data=with_no_data, + ) ) + return MaterializedView( + RemoteTable( + view.table, + self.db_name, + connection_state=self.serialize, + namespace_path=[], + ) + ) + + @override + def create_materialized_view_async( + self, + name: str, + source: str, + *, + select: SelectArg = None, + where: Optional[str] = None, + limit: Optional[int] = None, + with_no_data: bool = False, + ) -> Job[None]: + job = LOOP.run( + self._conn.create_materialized_view_async( + name, + source, + select=select, + where=where, + limit=limit, + with_no_data=with_no_data, + ) + ) + return Job(job) @override def open_materialized_view(self, name: str) -> MaterializedView: - raise NotImplementedError( - "materialized views are supported only on local databases" - ) + view = MaterializedView(self.open_table(name)) + view.definition + return view @override def list_materialized_views(self) -> List[str]: - raise NotImplementedError( - "materialized views are supported only on local databases" + return LOOP.run(self._conn.list_materialized_views()) + + @override + def drop_materialized_view( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> None: + if namespace_path is None: + namespace_path = [] + LOOP.run(self._conn.drop_materialized_view(name, namespace_path=namespace_path)) + + @override + def drop_materialized_view_async( + self, name: str, namespace_path: Optional[List[str]] = None + ) -> Job[None]: + if namespace_path is None: + namespace_path = [] + job = LOOP.run( + self._conn.drop_materialized_view_async(name, namespace_path=namespace_path) ) + return Job(job) @override def drop_table(self, name: str, namespace_path: Optional[List[str]] = None): @@ -732,36 +848,67 @@ class RemoteDBConnection(DBConnection): ) @override - def job(self, job_id: str) -> Job: - """A [Job][lancedb.job.Job] handle for a server-side job by id. - - The handle is constructed without a server round trip; an unknown id - surfaces when the handle is used. Dropping the handle has no effect - on the job itself. + def open_job(self, job_id: str) -> Job: + """Open a server-side job by id. See + [DBConnection.open_job][lancedb.db.DBConnection.open_job]. """ - return Job(self._conn.job(job_id)) + return Job(LOOP.run(self._conn.open_job(job_id))) @override - def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: - return Job(LOOP.run(self._conn.create_function_async(definition))) + def create_function_async( + self, + definition: UdfDefinition, + *, + secrets: Optional[Sequence[EnvVarSecret]] = None, + ) -> Job[FunctionVersion]: + job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets)) + return Job(job) @override def get_function(self, name: str, *, version: str) -> FunctionVersion: return LOOP.run(self._conn.get_function(name, version=version)) + @override + def list_functions(self) -> List[FunctionVersion]: + return LOOP.run(self._conn.list_functions()) + + @override + def drop_function(self, name: str, *, version: str) -> bool: + return LOOP.run(self._conn.drop_function(name, version=version)) + + @override + def create_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path)) + + @override + def alter_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path)) + + @override + def describe_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> SecretInfo: + return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path)) + + @override + def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]: + return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path)) + + @override + def drop_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path)) + @override def list_jobs(self) -> List["JobInfo"]: """List server-side jobs across the database's tables.""" return LOOP.run(self._conn.list_jobs()) - @override - def get_job(self, job_id: str) -> Optional["JobDescription"]: - """Describe a single server-side job by id. - - Returns None when the server has no such job. - """ - return LOOP.run(self._conn.get_job(job_id)) - @override def cancel_job(self, job_id: str) -> bool: """Request cancellation of a server-side job by id. @@ -773,12 +920,35 @@ class RemoteDBConnection(DBConnection): return LOOP.run(self._conn.cancel_job(job_id)) @override - def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]: - """The lifecycle event history of a server-side job, as Arrow batches. + def execute_query_async( + self, + query: str, + *, + default_namespace_path: Optional[List[str]] = None, + ) -> SqlQuery: + """Start executing SQL through this remote connection. - Lists history across all jobs when `job_id` is None. + Unqualified tables use this connection's database and the + ``["public"]`` namespace by default. Fully qualified table names may + reference other databases available to the same deployment. """ - return LOOP.run(self._conn.job_history(job_id)) + return SqlQuery( + LOOP.run( + self._conn.execute_query_async( + query, + default_namespace_path=default_namespace_path, + ) + ) + ) + + @override + def describe_query(self, query_id: UUID) -> QueryDescription: + """Describe a submitted SQL query by its connection-scoped id.""" + return LOOP.run( + self._conn.describe_query( + query_id, + ) + ) @override def namespace_client(self) -> LanceNamespace: diff --git a/python/python/lancedb/remote/header.py b/python/python/lancedb/remote/header.py index 06e3599f5..206b7a7cb 100644 --- a/python/python/lancedb/remote/header.py +++ b/python/python/lancedb/remote/header.py @@ -177,4 +177,7 @@ class OAuthProvider(HeaderProvider): if not self._current_token: raise RuntimeError("Failed to obtain OAuth token") - return {"Authorization": f"Bearer {self._current_token}"} + return { + "Authorization": f"Bearer {self._current_token}", + "x-lancedb-credential-type": "oidc", + } diff --git a/python/python/lancedb/remote/oauth.py b/python/python/lancedb/remote/oauth.py index 9175c3614..106418e95 100644 --- a/python/python/lancedb/remote/oauth.py +++ b/python/python/lancedb/remote/oauth.py @@ -12,10 +12,72 @@ class OAuthFlowType(str, Enum): CLIENT_CREDENTIALS = "client_credentials" """Client Credentials grant (service-to-service / M2M).""" + AUTHORIZATION_CODE = "authorization_code" + """Interactive Authorization Code grant, using PKCE by default.""" + + DEVICE_CODE = "device_code" + """Device Authorization grant for CLI and headless environments.""" + AZURE_MANAGED_IDENTITY = "azure_managed_identity" """Azure Managed Identity via IMDS.""" +class ClientAuthMethod(str, Enum): + """How the client authenticates to the OAuth token endpoint. + + The method applies to every OAuth request that carries client + authentication: client-credentials, authorization-code exchange, + refresh-token, and device-authorization requests. The Azure managed + identity flow ignores this option. + """ + + NONE = "none" + """No client authentication, for public clients using PKCE or the device + flow. Cannot be combined with ``client_secret``.""" + + CLIENT_SECRET_BASIC = "client_secret_basic" + """HTTP Basic authentication. This is the RFC 6749 recommended method and + the normal default for confidential clients, including default Okta + applications. Requires ``client_secret``.""" + + CLIENT_SECRET_POST = "client_secret_post" + """Credentials in the request body, for providers configured to require + it. Requires ``client_secret``.""" + + +@dataclass +class TokenCacheOptions: + """Options for the persistent OAuth token cache. + + The cache is opt-in: it is only used when set as ``token_cache`` on + :class:`OAuthConfig`. Only refresh tokens are persisted, in a private + directory with owner-only permissions, so short-lived processes can reuse + an authenticated session instead of re-prompting on every start. + + Parameters + ---------- + cache_dir : Optional[str] + Directory that holds cached credentials. Defaults to + ``$XDG_CACHE_HOME/lancedb/oauth``, ``$HOME/.cache/lancedb/oauth`` on + Unix, or ``%LOCALAPPDATA%\\lancedb\\oauth`` on Windows. The directory + is created with owner-only permissions (``0700``) when missing. + lock_timeout_secs : Optional[int] + How long to wait for the cross-process refresh lock before failing + (default: 30 seconds). + + Examples + -------- + >>> opts = TokenCacheOptions(cache_dir="/tmp/my-app/oauth-cache") + + Multiple identities (issuer, client, scopes, flow, client + authentication) get separate cache entries. Within one identity the most + recent login wins. + """ + + cache_dir: Optional[str] = None + lock_timeout_secs: Optional[int] = None + + @dataclass class OAuthConfig: """OAuth configuration for LanceDB authentication. @@ -38,12 +100,37 @@ class OAuthConfig: Authentication flow to use. Default: CLIENT_CREDENTIALS. client_secret : Optional[str] Client secret (required for CLIENT_CREDENTIALS). + client_auth_method : Optional[ClientAuthMethod] + How the client authenticates to the token endpoint (default: auto). + With a ``client_secret`` the default is + ``ClientAuthMethod.CLIENT_SECRET_BASIC``, which matches the RFC 6749 + recommendation and the default configuration of Okta confidential + applications; without a secret the client is public and no client + authentication is sent. + redirect_uri : Optional[str] + Loopback redirect URI for AUTHORIZATION_CODE. The default is + ``http://127.0.0.1:{callback_port}/callback``. + callback_port : Optional[int] + Port for the AUTHORIZATION_CODE loopback callback server (default: 8400). + use_pkce : bool + Protect AUTHORIZATION_CODE with S256 PKCE (default: True). managed_identity_client_id : Optional[str] Client ID for user-assigned managed identity (AZURE_MANAGED_IDENTITY). + resource : Optional[str] + Resource indicator (RFC 8707), forwarded verbatim to authorization and + token endpoints, including refresh requests. Must be an absolute URI + without a fragment. Not supported for Azure managed identity. + audience : Optional[str] + Provider-specific audience, forwarded to authorization and token + endpoints, including refresh requests. Not supported for Azure managed identity. refresh_buffer_secs : Optional[int] Seconds before expiry to trigger proactive refresh (default: 300). Keep this well below the token TTL; if it is greater than or equal to the TTL, each request refreshes the token. + token_cache : Optional[TokenCacheOptions] + Opt in to the persistent token cache so short-lived processes reuse + one session. Only supported by AUTHORIZATION_CODE and DEVICE_CODE; + azure managed identity is rejected. Default: None (memory only). Examples -------- @@ -56,6 +143,12 @@ class OAuthConfig: ... scopes=["api://lancedb-api/.default"], ... ) + Providers that require an explicit target can use ``resource`` and/or + ``audience`` (these are forwarded unchanged): + + >>> config.resource = "https://api.example.com" + >>> config.audience = "lancedb-api" + Azure Managed Identity: >>> config = OAuthConfig( @@ -64,6 +157,30 @@ class OAuthConfig: ... scopes=["api://lancedb-api/.default"], ... flow=OAuthFlowType.AZURE_MANAGED_IDENTITY, ... ) + + Authorization Code with PKCE: + + The authorization URL is written to standard error before LanceDB tries to + open a browser, so it can be copied in headless environments. + + >>> config = OAuthConfig( + ... issuer_url="https://login.microsoftonline.com/{tenant}/v2.0", + ... client_id="app-id", + ... scopes=["openid", "api://lancedb-api/access"], + ... flow=OAuthFlowType.AUTHORIZATION_CODE, + ... ) + + Device Authorization, with a persistent cache so later processes reuse + the session without a new device prompt. The verification URL and user + code are written to standard error before polling begins: + + >>> config = OAuthConfig( + ... issuer_url="https://login.microsoftonline.com/{tenant}/v2.0", + ... client_id="app-id", + ... scopes=["openid", "offline_access", "api://lancedb-api/access"], + ... flow=OAuthFlowType.DEVICE_CODE, + ... token_cache=TokenCacheOptions(), + ... ) """ issuer_url: str @@ -71,5 +188,73 @@ class OAuthConfig: scopes: List[str] flow: OAuthFlowType = OAuthFlowType.CLIENT_CREDENTIALS client_secret: Optional[str] = field(default=None, repr=False) + client_auth_method: Optional[ClientAuthMethod] = None + redirect_uri: Optional[str] = None + callback_port: Optional[int] = None + use_pkce: bool = True managed_identity_client_id: Optional[str] = None refresh_buffer_secs: Optional[int] = None + token_cache: Optional[TokenCacheOptions] = None + resource: Optional[str] = None + audience: Optional[str] = None + + +class OAuthSession: + """Explicit OAuth session lifecycle for the persistent token cache. + + Built from the same :class:`OAuthConfig` used for + :func:`lancedb.connect_async` (including its ``token_cache`` options). + A connection created with the same configuration shares the cache, so + logging in here prepares tokens for later processes without any database + request. + + ``login`` always runs the configured interactive flow and replaces the + cached session (the most recent login wins). ``logout`` removes only the + local credential; it does not revoke anything with the provider and does + not sign out of a browser SSO session. + + Examples + -------- + >>> config = OAuthConfig( + ... issuer_url="https://issuer.example.com", + ... client_id="my-app", + ... scopes=["openid", "offline_access"], + ... flow=OAuthFlowType.DEVICE_CODE, + ... token_cache=TokenCacheOptions(), + ... ) + >>> session = OAuthSession(config) # doctest: +SKIP + >>> status = await session.login() # doctest: +SKIP + >>> status.refreshable # doctest: +SKIP + True + """ + + def __init__(self, config: OAuthConfig): + from lancedb._lancedb import OAuthSession as PyOAuthSession + + self._inner: PyOAuthSession = PyOAuthSession(config) + + async def login(self): + """Eagerly run the configured flow and store the session. + + Returns a :class:`lancedb._lancedb.SessionStatus` describing the + cached session. A successful login always replaces any prior cached + session for this identity; if the provider does not issue a refresh + token (for example without ``offline_access``), the previous record + is removed and ``refreshable`` is ``False``. + """ + return await self._inner.login() + + async def status(self): + """Report whether a cached session exists, with safe metadata. + + Never contacts the identity provider and never exposes token values. + """ + return await self._inner.status() + + async def logout(self): + """Remove the matching local cached credential. + + Returns a :class:`lancedb._lancedb.SessionLogout` whose ``removed`` + flag reports whether a credential existed. Logout is idempotent. + """ + return await self._inner.logout() diff --git a/python/python/lancedb/remote/table.py b/python/python/lancedb/remote/table.py index 9488dbcf7..00f2e5ffd 100644 --- a/python/python/lancedb/remote/table.py +++ b/python/python/lancedb/remote/table.py @@ -549,6 +549,7 @@ class RemoteTable(Table): LOOP.run( self._table.create_index( column, + replace=replace, config=config, wait_timeout=wait_timeout, name=name, @@ -720,7 +721,7 @@ class RemoteTable(Table): Parameters ---------- query: list/np.ndarray/str/PIL.Image.Image, default None - The targetted vector to search for. + The targeted vector to search for. - *default None*. Acceptable types are: list, np.ndarray, PIL.Image.Image diff --git a/python/python/lancedb/rerankers/base.py b/python/python/lancedb/rerankers/base.py index 7bc7ff105..4af938a1e 100644 --- a/python/python/lancedb/rerankers/base.py +++ b/python/python/lancedb/rerankers/base.py @@ -175,7 +175,7 @@ class Reranker(ABC): if the results haven't been executed yet or the results in arrow format. query : str or None, The input query. Some rerankers might not need the query to rerank. - In that case, it can be set to None explicitly. This is inteded to + In that case, it can be set to None explicitly. This is intended to be handled by the reranker implementations. deduplicate : bool, optional Whether to deduplicate the results based on the `_rowid` column, diff --git a/python/python/lancedb/secrets.py b/python/python/lancedb/secrets.py new file mode 100644 index 000000000..75ac9e340 --- /dev/null +++ b/python/python/lancedb/secrets.py @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +"""Named Secrets, and the bindings that deliver them to Functions. + +A Secret is a database-scoped named credential. Nothing in this module holds a +value: :class:`EnvVarSecret` names one and says which environment variable it +should arrive in, and the value is resolved by the remote service when a +Function bound to it runs. No API returns a stored credential, by construction +rather than by policy -- there is no code path that could. +""" + +from __future__ import annotations + +import re + +# The same characters LanceDB already admits in a namespace or table name, and +# no positional rule on top of them: a segment may begin with `_`, `-` or `.` +# today, so anything narrower would put Secrets out of reach inside namespaces +# that already exist. Matches the service, which admits the same set. +_SECRET_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,255}$") +_ENV_VARIABLE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def validate_secret_name(name: str) -> str: + """Check a Secret name locally and return it unchanged.""" + if not isinstance(name, str): + raise TypeError(f"Secret name must be a string, not {type(name).__name__}") + if not _SECRET_NAME.fullmatch(name): + raise ValueError(f"invalid Secret name: {name!r}") + return name + + +def validate_namespace_path(namespace_path=None): + """Check a namespace path locally and return it as a tuple. + + ``None`` and ``[]`` both mean the root namespace. Segments follow the same + rule as Secret names: a binding carries the path and the name as separate + fields, so neither is ever parsed out of the other. + """ + if namespace_path is None: + return () + if isinstance(namespace_path, str): + raise TypeError( + "namespace_path must be a list of segments, not a string; " + f"did you mean [{namespace_path!r}]?" + ) + segments = tuple(namespace_path) + for segment in segments: + if not isinstance(segment, str): + raise TypeError( + f"namespace path segment must be a string, not {type(segment).__name__}" + ) + if not _SECRET_NAME.fullmatch(segment): + raise ValueError(f"invalid namespace path segment: {segment!r}") + return segments + + +def validate_env_variable(name: str) -> str: + """Check an environment variable name locally and return it unchanged.""" + if not isinstance(name, str): + raise TypeError( + f"environment variable name must be a string, not {type(name).__name__}" + ) + if not _ENV_VARIABLE.fullmatch(name): + raise ValueError(f"invalid environment variable name: {name!r}") + return name + + +class EnvVarSecret: + """A Secret bound to the environment variable a Function's library reads. + + Pass these in the ``secrets`` sequence of + [DBConnection.create_function][lancedb.db.DBConnection.create_function]. The + Function's source is unchanged by binding: it reads ``OPENAI_API_KEY`` the + way it always did, and the binding is what puts a value there. + + This is a local value. Constructing it contacts no server, so it always + succeeds and says nothing about whether the Secret exists; that is checked + at registration, where a mistyped Secret name surfaces as a clear "does not + exist" naming both the Secret and the variable bound to it. A mistyped + *variable* name cannot be caught anywhere -- nothing knows which variables a + Function reads -- so it surfaces on the first rows instead. + + The type exists so a credential cannot be passed by accident. A bare string + in the same position is a plausible-looking mistake with the opposite + meaning, and it reads identically in a diff. + + Parameters + ---------- + secret_name : str + The Secret's database-scoped name. + env_variable : str + The environment variable the Function reads it from. + secret_namespace_path : list of str, optional + The namespace the Secret is addressed within. ``None`` and ``[]`` both + mean the root namespace. Carried beside the name rather than joined + into it, so neither is ever parsed back out of the other. + + Examples + -------- + >>> from lancedb import EnvVarSecret + >>> binding = EnvVarSecret( + ... secret_name="openai-prod", env_variable="OPENAI_API_KEY" + ... ) + >>> binding.secret_name, binding.env_variable + ('openai-prod', 'OPENAI_API_KEY') + """ + + __slots__ = ("_secret_name", "_env_variable", "_secret_namespace_path") + + def __init__( + self, secret_name: str, env_variable: str, *, secret_namespace_path=None + ): + self._secret_name = validate_secret_name(secret_name) + self._env_variable = validate_env_variable(env_variable) + self._secret_namespace_path = validate_namespace_path(secret_namespace_path) + + @property + def secret_name(self) -> str: + """The Secret's database-scoped name.""" + return self._secret_name + + @property + def env_variable(self) -> str: + """The environment variable the value is delivered in.""" + return self._env_variable + + @property + def secret_namespace_path(self): + """The namespace path the Secret is addressed within, root when empty.""" + return list(self._secret_namespace_path) + + def __repr__(self) -> str: + path = ( + f", secret_namespace_path={list(self._secret_namespace_path)!r}" + if self._secret_namespace_path + else "" + ) + return ( + f"EnvVarSecret(secret_name={self._secret_name!r}, " + f"env_variable={self._env_variable!r}{path})" + ) + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, EnvVarSecret) + and other._secret_name == self._secret_name + and other._env_variable == self._env_variable + and other._secret_namespace_path == self._secret_namespace_path + ) + + def __hash__(self) -> int: + return hash( + ( + EnvVarSecret, + self._secret_name, + self._env_variable, + self._secret_namespace_path, + ) + ) + + +class SecretInfo: + """What a database records about a Secret. Never its value. + + Returned by + [DBConnection.describe_secret][lancedb.db.DBConnection.describe_secret]. + """ + + __slots__ = ("_name", "_created_at_millis", "_updated_at_millis") + + def __init__(self, name: str, created_at_millis: int, updated_at_millis: int): + self._name = name + self._created_at_millis = created_at_millis + self._updated_at_millis = updated_at_millis + + @property + def name(self) -> str: + """The Secret's database-scoped name.""" + return self._name + + @property + def created_at_millis(self) -> int: + """When the Secret was created, in milliseconds since the Unix epoch.""" + return self._created_at_millis + + @property + def updated_at_millis(self) -> int: + """When the Secret's value was last rotated, in epoch milliseconds. + + The only observable that a rotation landed: no API returns a credential, + so a caller confirms ``alter_secret`` took effect by watching this move. + """ + return self._updated_at_millis + + @classmethod + def from_json(cls, value: dict) -> "SecretInfo": + return cls( + name=value["name"], + created_at_millis=value["created_at_millis"], + updated_at_millis=value["updated_at_millis"], + ) + + def __repr__(self) -> str: + return ( + f"SecretInfo(name={self._name!r}, " + f"created_at_millis={self._created_at_millis!r}, " + f"updated_at_millis={self._updated_at_millis!r})" + ) + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, SecretInfo) + and other._name == self._name + and other._created_at_millis == self._created_at_millis + and other._updated_at_millis == self._updated_at_millis + ) + + +__all__ = [ + "EnvVarSecret", + "SecretInfo", + "validate_env_variable", + "validate_secret_name", +] diff --git a/python/python/lancedb/sql.py b/python/python/lancedb/sql.py new file mode 100644 index 000000000..41bbb5328 --- /dev/null +++ b/python/python/lancedb/sql.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +"""Handles to SQL queries running on a remote database.""" + +from uuid import UUID + +import pyarrow as pa + +from lancedb.background_loop import LOOP + +from . import _lancedb +from .arrow import AsyncRecordBatchReader + +QueryDescription = _lancedb.QueryDescription + + +class AsyncQuery: + """A handle to a submitted SQL query on an asynchronous connection.""" + + def __init__(self, inner: "_lancedb.SqlQuery"): + self._inner = inner + + @property + def id(self) -> UUID: + """The stable identifier scoped to the connection that submitted it.""" + return self._inner.id + + async def describe(self) -> QueryDescription: + """Get a point-in-time description of the query.""" + return await self._inner.describe() + + async def reader(self) -> AsyncRecordBatchReader: + """Wait for the initial result stream and return its Arrow reader. + + Results are single-consumer. Calling this method more than once on the + same query raises an error. Later batches are streamed as they become + available without waiting for the full query to finish. + """ + return AsyncRecordBatchReader(await self._inner.reader()) + + async def cancel(self) -> None: + """Request cancellation of the query.""" + await self._inner.cancel() + + +class Query: + """Synchronous counterpart of :class:`AsyncQuery`.""" + + def __init__(self, inner: AsyncQuery): + self._inner = inner + + @property + def id(self) -> UUID: + """The stable identifier scoped to the connection that submitted it.""" + return self._inner.id + + def describe(self) -> QueryDescription: + """Get a point-in-time description of the query.""" + return LOOP.run(self._inner.describe()) + + def reader(self) -> pa.RecordBatchReader: + """Wait for the initial result stream and return a blocking reader. + + Results are single-consumer. Calling this method more than once on the + same query raises an error. Later batches block only until they become + available, without waiting for the full query to finish. + """ + reader = LOOP.run(self._inner.reader()) + + def next_batch(): + try: + return LOOP.run(reader.__anext__()) + except StopAsyncIteration: + return None + + def batches(): + while (batch := next_batch()) is not None: + yield batch + + return pa.RecordBatchReader.from_batches(reader.schema, batches()) + + def cancel(self) -> None: + """Request cancellation of the query.""" + LOOP.run(self._inner.cancel()) + + +__all__ = ["AsyncQuery", "Query", "QueryDescription"] diff --git a/python/python/lancedb/table.py b/python/python/lancedb/table.py index 5039cfd32..fd79e3557 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -717,26 +717,43 @@ def _align_field_types( return new_fields -def _align_list_value_field( - value_field: pa.Field, target_value_field: pa.Field -) -> pa.Field: - # A list has exactly one child, so the inferred child name ("item") aligns - # positionally and adopts the table's child name; pa.Table.cast renames it. - return _align_field(value_field, target_value_field).with_name( - target_value_field.name - ) +def _align_container_child(child: pa.Field, target_child: pa.Field) -> pa.Field: + # A list has one child, a map one key and one item, so an inferred child name + # ("item") aligns positionally and adopts the table's; pa.Table.cast renames it. + return _align_field(child, target_child).with_name(target_child.name) + + +def _arrow_json_storage_type(input_type: pa.DataType) -> Optional[pa.DataType]: + """The storage type arrow.json would use for ``input_type``. + + Returns None if the type cannot hold JSON text. + """ + if pa.types.is_string(input_type) or pa.types.is_string_view(input_type): + return pa.string() + if pa.types.is_large_string(input_type): + return pa.large_string() + return None def _align_field(field: pa.Field, target_field: pa.Field) -> pa.Field: - # Preserve arrow.json input until it reaches Lance. LanceDB exposes stored - # JSON columns as lance.json (JSONB-backed LargeBinary), but casting the - # input to that storage type here merely relabels the raw JSON bytes as + # LanceDB exposes stored JSON columns as lance.json (JSONB-backed LargeBinary), but + # casting the input to that storage type here merely relabels the raw JSON bytes as # JSONB. Lance must see arrow.json so it can perform the JSONB encoding. - if ( - _field_extension_name(field) == "arrow.json" - and _field_extension_name(target_field) == "lance.json" - ): - return field + if _field_extension_name(target_field) == "lance.json": + if _field_extension_name(field) == "arrow.json": + return field + # Plain JSON text, which is what pyarrow infers for a column of `str`, only + # needs the arrow.json label. + json_storage = _arrow_json_storage_type(field.type) + if json_storage is not None: + # Labelled through metadata rather than pa.json_(), which only exists on + # newer PyArrow; Lance reads the extension name off the field either way. + return pa.field( + field.name, + json_storage, + field.nullable, + {"ARROW:extension:name": "arrow.json"}, + ) if pa.types.is_struct(target_field.type): if pa.types.is_struct(field.type): new_type = pa.struct( @@ -750,7 +767,7 @@ def _align_field(field: pa.Field, target_field: pa.Field) -> pa.Field: elif pa.types.is_list(target_field.type): if _is_list_like(field.type): new_type = pa.list_( - _align_list_value_field( + _align_container_child( field.type.value_field, target_field.type.value_field ) ) @@ -759,7 +776,7 @@ def _align_field(field: pa.Field, target_field: pa.Field) -> pa.Field: elif pa.types.is_large_list(target_field.type): if _is_list_like(field.type): new_type = pa.large_list( - _align_list_value_field( + _align_container_child( field.type.value_field, target_field.type.value_field ) ) @@ -768,13 +785,28 @@ def _align_field(field: pa.Field, target_field: pa.Field) -> pa.Field: elif pa.types.is_fixed_size_list(target_field.type): if _is_list_like(field.type): new_type = pa.list_( - _align_list_value_field( + _align_container_child( field.type.value_field, target_field.type.value_field ), target_field.type.list_size, ) else: new_type = target_field.type + elif pa.types.is_map(target_field.type): + if pa.types.is_map(field.type): + # A map has exactly one key and one item field, so like a list's child they + # align positionally and adopt the table's names. + new_type = pa.map_( + _align_container_child( + field.type.key_field, target_field.type.key_field + ), + _align_container_child( + field.type.item_field, target_field.type.item_field + ), + keys_sorted=target_field.type.keys_sorted, + ) + else: + new_type = target_field.type else: new_type = target_field.type return pa.field(field.name, new_type, field.nullable, target_field.metadata) @@ -1630,7 +1662,9 @@ class Table(ABC): on: Union[str, Iterable[str]] A column (or columns) to join on. This is how records from the source table and target table are matched. Typically this is some - kind of key or id column. + kind of key or id column. Passing several columns matches on the + composite key: a source row updates a target row only when it + agrees on every one of them. Examples -------- @@ -1700,7 +1734,7 @@ class Table(ABC): Parameters ---------- query: list/np.ndarray/str/PIL.Image.Image, default None - The targetted vector to search for. + The targeted vector to search for. - *default None*. Acceptable types are: list, np.ndarray, PIL.Image.Image @@ -1761,9 +1795,9 @@ class Table(ABC): Offsets are mostly useful for sampling as the set of all valid offsets is easily known in advance to be [0, len(table)). - No guarantees are made regarding the order in which results are returned. If - you desire an output order that matches the order of the given offsets, you will - need to add the row offset column to the output and align it yourself. + No guarantees are made regarding the order in which results are returned. + Repeated offsets produce repeated rows, which makes this method suitable for + sampling with replacement. Parameters ---------- @@ -1874,6 +1908,9 @@ class Table(ABC): The result has the same length and order as ``row_ids``. Null blobs produce null slots; valid empty blobs produce ``b""``. + ``_rowid`` values stay valid after compaction when the table has stable + row ids. + Convenience for small payloads. For large values use :meth:`fetch_blob_files`. """ @@ -1891,6 +1928,9 @@ class Table(ABC): The result has the same length and order as ``requests``; null blobs produce null slots and empty ranges on non-null blobs produce ``b""``. + ``_rowid`` values stay valid after compaction when the table has stable + row ids. + Row IDs can be obtained from a query with ``with_row_id(True)``. This API is currently supported only by local tables. """ @@ -1906,6 +1946,9 @@ class Table(ABC): ``_rowid`` or a ``_lance_row_id`` field on the blob descriptor. Null rows are ``None``. Remote tables require LanceDB Cloud server 0.5.0 or newer. + + ``_rowid`` values stay valid after compaction when the table has stable + row ids. """ @abstractmethod @@ -2266,10 +2309,10 @@ class Table(ABC): Declaring one therefore costs the same on a large table as on an empty one. - A refresh does not revisit rows it has already filled, so mutating - an input leaves the value computed at fill time; recomputing means - dropping the column and declaring it again. While a declaration - reads a column, that column cannot be renamed, retyped or dropped. + A refresh also recomputes the rows whose inputs changed since they + were computed, so a mutated input is reflected by the next refresh. + While a declaration reads a column, that column cannot be renamed, + retyped or dropped. On LanceDB Cloud and Enterprise the expression is planned by the server, and the refresh runs as a server job -- see @@ -2289,7 +2332,7 @@ class Table(ABC): >>> table.add_columns(computed={"doubled": "x * 2"}) AddColumnsResult(version=2) >>> table.refresh_column("doubled") - RefreshColumnResult(rows_filled=2, version=3) + RefreshColumnResult(rows_filled=2, version=4) >>> table.to_arrow().sort_by("x").to_pandas() x doubled 0 1 2 @@ -2303,8 +2346,8 @@ class Table(ABC): Declared with ``add_columns(computed=...)``, a column starts empty and gets its values here. Rows appended since the last refresh are filled - by the next one; rows already filled are left as they are, so the call - is idempotent and does not observe a mutated input. + by the next one, and rows whose inputs changed since they were computed + are recomputed; everything else is left as it is. Local tables only: a remote refresh runs as a server job, through [`refresh_column_async`][lancedb.table.Table.refresh_column_async]. @@ -3919,7 +3962,7 @@ class LanceTable(Table): Parameters ---------- query: list/np.ndarray/str/PIL.Image.Image, default None - The targetted vector to search for. + The targeted vector to search for. - *default None*. Acceptable types are: list, np.ndarray, PIL.Image.Image @@ -4179,6 +4222,7 @@ class LanceTable(Table): ) and not self._route_pushdown_to_rust and self.current_branch() is None + and query.take_offsets is None ): from lancedb.namespace import _execute_server_side_query @@ -4402,13 +4446,14 @@ class LanceTable(Table): return LOOP.run(self._table.add_columns(transforms, computed=computed)) def refresh_column(self, column: str) -> "RefreshColumnResult": - """Fill a computed column's unfilled rows. See + """Fill a computed column's unfilled rows and recompute those whose + inputs changed. See [`AsyncTable.refresh_column`][lancedb.AsyncTable.refresh_column].""" return LOOP.run(self._table.refresh_column(column)) def refresh_column_async(self, column: str) -> Job[RefreshColumnJobResult]: - """Fill a computed column's unfilled rows, returning a handle to the - refresh job. See + """Fill a computed column's unfilled rows and recompute those whose + inputs changed, returning a handle to the refresh job. See [`Table.refresh_column_async`][lancedb.table.Table.refresh_column_async]. """ return Job(LOOP.run(self._table.refresh_column_async(column))) @@ -5722,7 +5767,7 @@ class AsyncTable: if fill_value is None: fill_value = 0.0 - # _santitize_data is an old code path, but we will use it until the + # _sanitize_data is an old code path, but we will use it until the # new code path is ready. if mode == "overwrite": # For overwrite, apply the same preprocessing as create_table @@ -5796,7 +5841,9 @@ class AsyncTable: on: Union[str, Iterable[str]] A column (or columns) to join on. This is how records from the source table and target table are matched. Typically this is some - kind of key or id column. + kind of key or id column. Passing several columns matches on the + composite key: a source row updates a target row only when it + agrees on every one of them. Examples -------- @@ -5896,7 +5943,7 @@ class AsyncTable: Parameters ---------- query: list/np.ndarray/str/PIL.Image.Image, default None - The targetted vector to search for. + The targeted vector to search for. - *default None*. Acceptable types are: list, np.ndarray, PIL.Image.Image @@ -6079,7 +6126,23 @@ class AsyncTable: def _sync_query_to_async( self, query: Query - ) -> AsyncHybridQuery | AsyncFTSQuery | AsyncVectorQuery | AsyncQuery: + ) -> ( + AsyncHybridQuery + | AsyncFTSQuery + | AsyncVectorQuery + | AsyncQuery + | AsyncTakeQuery + ): + if query.take_offsets is not None: + take_query = self.take_offsets(query.take_offsets) + if query.columns: + take_query = take_query.select(query.columns) + if query.use_lsm is not None: + take_query = take_query.use_lsm(query.use_lsm) + if query.with_row_id: + take_query = take_query.with_row_id() + return take_query + async_query = self.query() if query.limit is not None: async_query = async_query.limit(query.limit) @@ -6144,6 +6207,7 @@ class AsyncTable: self._namespace_client, self._pushdown_operations ) and not self._route_pushdown_to_rust + and query.take_offsets is None ): from lancedb.namespace import _execute_server_side_query @@ -6377,10 +6441,10 @@ class AsyncTable: them from [`refresh_column`][lancedb.table.AsyncTable.refresh_column]. - A refresh does not revisit rows it has already filled, so mutating - an input leaves the value computed at fill time. While a - declaration reads a column, that column cannot be renamed, retyped - or dropped. + A refresh also recomputes the rows whose inputs changed since they + were computed, so a mutated input is reflected by the next refresh. + While a declaration reads a column, that column cannot be renamed, + retyped or dropped. On LanceDB Cloud and Enterprise the expression is planned by the server. Cannot be combined with ``transforms``. @@ -6442,8 +6506,8 @@ class AsyncTable: Declared with ``add_columns(computed=...)``, a column starts empty and gets its values here. Rows appended since the last refresh are filled - by the next one; rows already filled are left as they are, so the call - is idempotent and does not observe a mutated input. + by the next one, and rows whose inputs changed since they were computed + are recomputed; everything else is left as it is. Local tables only: a remote refresh runs as a server job, through [`refresh_column_async`][lancedb.table.Table.refresh_column_async]. @@ -6641,6 +6705,9 @@ class AsyncTable: Offsets are mostly useful for sampling as the set of all valid offsets is easily known in advance to be [0, len(table)). + No guarantees are made regarding the order in which results are returned. + Repeated offsets produce repeated rows. + Parameters ---------- offsets: list[int] diff --git a/python/python/tests/test_blob.py b/python/python/tests/test_blob.py index c9694277c..0d3a89c21 100644 --- a/python/python/tests/test_blob.py +++ b/python/python/tests/test_blob.py @@ -66,6 +66,25 @@ def _row_ids_by_id(table): return dict(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist())) +def _assert_missing_blob_row_ids(exc_info): + message = str(exc_info.value) + assert "row ids" in message + assert "rowaddr" not in message + assert "fragment" not in message + + +def _assert_fetch_apis_reject_missing_row_ids(table, row_ids): + with pytest.raises(ValueError) as exc_info: + table.fetch_blobs("image", row_ids) + _assert_missing_blob_row_ids(exc_info) + with pytest.raises(ValueError) as exc_info: + table.fetch_blob_files("image", row_ids) + _assert_missing_blob_row_ids(exc_info) + with pytest.raises(ValueError) as exc_info: + table.fetch_blob_ranges("image", [(row_id, 0, 1) for row_id in row_ids]) + _assert_missing_blob_row_ids(exc_info) + + def test_blob_factory_declares_v2_field(): field = lancedb.blob("image") assert isinstance(field.type, pa.ExtensionType) @@ -278,7 +297,10 @@ def test_blob_v2_projection_sources_use_typed_column_name(): def _legacy_v1_table(name): - db = lancedb.connect("memory:///") + # Legacy v1 blob columns are only writable at file version <= 2.1. + db = lancedb.connect( + "memory:///", storage_options={"new_table_data_storage_version": "2.1"} + ) schema = pa.schema( [ pa.field("id", pa.int64()), @@ -691,6 +713,25 @@ def test_fetch_blobs_accepts_query_result(): assert {blobs[i].as_py() for i in range(len(blobs))} == {b"gamma"} +def test_fetch_blobs_after_compact_with_stable_row_ids(tmp_path): + db = lancedb.connect( + tmp_path, storage_options={"new_table_enable_stable_row_ids": "true"} + ) + schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")]) + table = db.create_table("t", schema=schema) + table.add([{"id": 1, "image": b"frag-one"}]) + table.add([{"id": 2, "image": b"frag-two"}]) + by_id = _row_ids_by_id(table) + ids = [by_id[1], by_id[2]] + + table.optimize() + + blobs = table.fetch_blobs("image", ids) + assert blobs.to_pylist() == [b"frag-one", b"frag-two"] + ranges = table.fetch_blob_ranges("image", [(ids[0], 5, 3), (ids[1], 5, 3)]) + assert ranges.to_pylist() == [b"one", b"two"] + + def test_fetch_blobs_preserves_null_and_empty_values(): table = _blob_table( "nulls", @@ -710,6 +751,80 @@ def test_fetch_blobs_preserves_null_and_empty_values(): assert blobs[3].as_py() == b"present" +def test_add_all_null_list_to_blob_column(): + table = _blob_table("all_null_add", [{"id": 1, "image": None}]) + + hits = table.search().to_arrow() + blobs = table.fetch_blobs("image", hits) + assert len(blobs) == 1 + assert blobs[0].as_py() is None + + +def test_add_all_null_list_to_blob_column_with_sanitizer(): + db = lancedb.connect("memory:///") + schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")]) + table = db.create_table("all_null_sanitized_add", schema=schema) + + table.add([{"id": 1, "image": None}], on_bad_vectors="fill") + + hits = table.search().to_arrow() + blobs = table.fetch_blobs("image", hits) + assert len(blobs) == 1 + assert blobs[0].as_py() is None + + +def test_add_all_null_list_to_nested_blob_column(): + db = lancedb.connect("memory:///") + blob_field = lancedb.blob("image") + info_field = pa.field("info", pa.struct([blob_field])) + info = pa.StructArray.from_arrays( + [_blob_array("image", [b"seed"])], fields=[blob_field] + ) + seed = pa.Table.from_arrays( + [pa.array([0], type=pa.int64()), info], + schema=pa.schema([pa.field("id", pa.int64()), info_field]), + ) + table = db.create_table("nested_null_add", data=seed) + + table.add([{"id": 1, "info": {"image": None}}]) + table.add([{"id": 2, "info": {"image": None}}], on_bad_vectors="fill") + + hits = table.search().where("id > 0").to_arrow() + blobs = table.fetch_blobs("info.image", hits) + assert len(blobs) == 2 + assert all(blob.as_py() is None for blob in blobs) + + +@pytest.mark.parametrize("large_list", [False, True], ids=["list", "large_list"]) +def test_add_list_of_dicts_to_blob_list_column(large_list): + db = lancedb.connect("memory:///") + blob_field = lancedb.blob("image") + blob_values = _blob_array("image", [b"seed"]) + if large_list: + items_field = pa.field("items", pa.large_list(blob_field)) + items = pa.LargeListArray.from_arrays( + pa.array([0, 1], type=pa.int64()), blob_values + ) + else: + items_field = pa.field("items", pa.list_(blob_field)) + items = pa.ListArray.from_arrays(pa.array([0, 1], type=pa.int32()), blob_values) + seed = pa.Table.from_arrays( + [pa.array([0], type=pa.int64()), items], + schema=pa.schema([pa.field("id", pa.int64()), items_field]), + ) + table = db.create_table(f"blob_{large_list}_list_add", data=seed) + + table.add([{"id": 1, "items": [None]}]) + table.add( + [{"id": 2, "items": [b"a", None]}], + on_bad_vectors="fill", + ) + + ids = table.search().select(["id"]).to_arrow()["id"].to_pylist() + assert sorted(ids) == [0, 1, 2] + assert pa.types.is_large_list(table.schema.field("items").type) is large_list + + def test_fetch_blob_ranges_aligns_repeated_ranges_and_nulls(): table = _blob_table( "range_alignment", @@ -739,8 +854,25 @@ def test_fetch_blob_ranges_validates_requests(): with pytest.raises(ValueError, match="offset \\+ length overflowed"): table.fetch_blob_ranges("image", [(row_id, 2**64 - 1, 1)]) - with pytest.raises(ValueError, match="row IDs"): + with pytest.raises(ValueError) as exc_info: table.fetch_blob_ranges("image", [(2**64 - 1, 0, 1)]) + _assert_missing_blob_row_ids(exc_info) + + +def test_fetch_blob_apis_reject_missing_fragment_row_addr(): + table = _blob_table("missing_frag", [{"id": 1, "image": b"x"}]) + live = _row_ids_by_id(table)[1] + _assert_fetch_apis_reject_missing_row_ids(table, [1 << 32, live]) + + +def test_fetch_blob_apis_reject_deleted_row_ids(): + table = _blob_table( + "deleted_rows", + [{"id": 1, "image": b"one"}, {"id": 2, "image": b"two"}], + ) + by_id = _row_ids_by_id(table) + table.delete("id = 2") + _assert_fetch_apis_reject_missing_row_ids(table, [by_id[2], by_id[1]]) def test_fetch_blob_ranges_empty_requests_returns_empty_array(): diff --git a/python/python/tests/test_catalog.py b/python/python/tests/test_catalog.py new file mode 100644 index 000000000..ca5d68fdf --- /dev/null +++ b/python/python/tests/test_catalog.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from threading import Thread + +import pytest + +import lancedb +from lancedb.db import AsyncConnection, DBConnection +from lancedb.remote.errors import HttpError + + +@pytest.fixture +def catalog_server(): + requests = [] + responses = [] + + class Handler(BaseHTTPRequestHandler): + def handle_request(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + requests.append( + ( + self.path, + dict(self.headers.items()), + json.loads(body) if body else None, + ) + ) + status, response = responses.pop(0) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + if status != 204: + self.wfile.write(json.dumps(response).encode()) + + do_GET = handle_request + do_POST = handle_request + + with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", requests, responses + finally: + server.shutdown() + thread.join() + + +def test_catalog_sync_scope_and_serialization(catalog_server): + endpoint, requests, responses = catalog_server + responses.extend( + [ + (204, None), + (200, {}), + (200, {"tables": []}), + (200, {"tables": []}), + (200, {"namespaces": ["team/search"], "page_token": "next"}), + (204, None), + ] + ) + catalog = lancedb.connect_catalog( + endpoint, + api_key="secret", + sql_host_override="invalid://localhost", + client_config={ + "extra_headers": { + "X-LanceDB-Database": "wrong", + "X-LanceDB-Database-Prefix": "wrong", + } + }, + ) + assert isinstance(catalog, lancedb.Catalog) + assert catalog.uri == endpoint + db = catalog.create_database("team/search", exist_ok=True) + assert isinstance(db, DBConnection) + with pytest.raises(ValueError, match="sql_host_override must use"): + db.execute_query_async("SELECT 1") + db = catalog.connect_database("team/search") + assert isinstance(db, DBConnection) + assert db.table_names() == [] + restored = lancedb.deserialize_conn(db.serialize()) + assert restored.sql_host_override == "invalid://localhost" + for connection in (db, restored): + with pytest.raises(ValueError, match="sql_host_override must use"): + connection.execute_query_async("SELECT 1") + assert restored.table_names() == [] + page = catalog.list_databases(limit=1, page_token="a/b") + assert page == lancedb.ListDatabasesResponse(["team/search"], "next") + catalog.drop_database("team/search", ignore_missing=True) + assert requests[0][0] == "/v1/namespace/team%2Fsearch/create" + assert requests[0][2] == {"mode": "ExistOk"} + assert requests[1][0] == "/v1/namespace/team%2Fsearch/describe" + assert requests[4][0] == "/v1/namespace/$/list?limit=1&page_token=a%2Fb" + assert requests[5][2] == {"mode": "Skip", "behavior": "Restrict"} + for i, (_, headers, _) in enumerate(requests): + headers = {key.lower(): value for key, value in headers.items()} + assert headers.get("x-lancedb-database") == ( + "team/search" if i in (2, 3) else None + ) + assert "x-lancedb-database-prefix" not in headers + assert headers["x-api-key"] == "secret" + + +@pytest.mark.asyncio +async def test_catalog_async_and_errors(catalog_server): + endpoint, requests, responses = catalog_server + responses.extend( + [ + (200, {}), + (200, {"tables": []}), + (404, {"error": "missing"}), + (409, {"error": "exists"}), + (400, {"error": "not empty"}), + (404, {"error": "missing"}), + ] + ) + catalog = await lancedb.connect_catalog_async( + endpoint, sql_host_override="invalid://localhost" + ) + assert isinstance(catalog, lancedb.AsyncCatalog) + db = await catalog.connect_database("analytics") + assert isinstance(db, AsyncConnection) + with pytest.raises(ValueError, match="sql_host_override must use"): + await db.execute_query_async("SELECT 1") + assert await db.table_names() == [] + with pytest.raises(ValueError, match="missing"): + await catalog.connect_database("missing") + with pytest.raises(ValueError, match="exists"): + await catalog.create_database("exists") + with pytest.raises(HttpError): + await catalog.drop_database("full") + await catalog.drop_database("missing", ignore_missing=True) + assert requests[4][2] == {"mode": "Fail", "behavior": "Restrict"} + + +@pytest.mark.parametrize("endpoint", ["/tmp/catalog", "s3://bucket", "db://db"]) +def test_catalog_requires_remote_endpoint(endpoint): + with pytest.raises(ValueError, match="endpoint"): + lancedb.connect_catalog(endpoint) diff --git a/python/python/tests/test_embeddings.py b/python/python/tests/test_embeddings.py index 9850669eb..a57a495ee 100644 --- a/python/python/tests/test_embeddings.py +++ b/python/python/tests/test_embeddings.py @@ -327,8 +327,8 @@ def test_embedding_function_with_pandas(tmp_path): ) -> List[np.array]: return [np.random.randn(self.ndims()).tolist() for _ in range(len(texts))] - registery = get_registry() - func = registery.get("mock-embedding").create() + registry = get_registry() + func = registry.get("mock-embedding").create() class TestSchema(LanceModel): text: str = func.SourceField() @@ -394,9 +394,9 @@ def test_multiple_embeddings_for_pandas(tmp_path): ) -> List[np.array]: return [np.random.randn(self.ndims()).tolist() for _ in range(len(texts))] - registery = get_registry() - func1 = registery.get("mock-embedding").create() - func2 = registery.get("mock-embedding2").create() + registry = get_registry() + func1 = registry.get("mock-embedding").create() + func2 = registry.get("mock-embedding2").create() class TestSchema(LanceModel): text: str = func1.SourceField() diff --git a/python/python/tests/test_first_class_function_slice1.py b/python/python/tests/test_first_class_function_slice1.py index 89172ba3f..fbcfa03b1 100644 --- a/python/python/tests/test_first_class_function_slice1.py +++ b/python/python/tests/test_first_class_function_slice1.py @@ -13,7 +13,9 @@ from lancedb.functions import ( FunctionBinding, FunctionVersion, PythonRuntimeSpec, + SecretBinding, RefreshColumnResult, + SecretReference, ) from lancedb.table import AsyncTable @@ -37,6 +39,22 @@ def job_result(name: str) -> dict: return json.loads(fixture(name))["result"] +def assert_no_secret_values(value): + """No client value models a resolved credential, at any nesting depth.""" + if isinstance(value, dict): + for key, child in value.items(): + assert key not in { + "secret_value", + "secret_values", + "resolved_secret", + "resolved_secrets", + } + assert_no_secret_values(child) + elif isinstance(value, list): + for child in value: + assert_no_secret_values(child) + + def test_public_function_values_are_in_api_reference(): docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md" rendered = docs.read_text() @@ -93,16 +111,27 @@ def test_function_version_identity_is_immutable_and_exact(): value = job_result("remote_function_job.json") version = FunctionVersion.from_json(json.dumps(value)) assert version.name == "embed" - assert version.version == "fv_01K3EXACT" + assert version.version == "1" + assert version.image.manifest_digest.startswith("sha256:") + assert version.version != version.image.manifest_digest + assert list(version.secret_bindings) == [ + SecretBinding( + kind="env", variable="HF_TOKEN", secret_ref=SecretReference(name="hf-prod") + ) + ] with pytest.raises((TypeError, ValueError)): - version.version = "fv_changed" + version.version = "1" with pytest.raises(TypeError, match="immutable"): - version.runtime.env["TOKENIZERS_PARALLELISM"] = "true" + version.image.descriptor["format_version"] = "changed" changed = dict(value) - changed["version"] = "fv_changed" + changed["version"] = "2" assert FunctionVersion(**changed) != version + assert FunctionVersion(**changed).image == version.image + for invalid in [version.image.manifest_digest, "0", "01", "-1", str(2**64)]: + with pytest.raises(ValueError): + FunctionVersion(**{**value, "version": invalid}) def test_function_version_binds_named_columns_as_one_immutable_application(): @@ -137,7 +166,7 @@ def test_function_version_binding_validates_names_and_direct_columns(): def test_function_version_keeps_named_struct_outputs_in_one_application(): value = job_result("remote_function_job.json") value["name"] = "text_features" - value["version"] = "fv_multi_output" + value["version"] = "1" value["signature"] = { "inputs": [ {"name": "title", "arrow_type": "utf8", "nullable": True}, @@ -182,14 +211,14 @@ def test_function_version_keeps_named_struct_outputs_in_one_application(): def test_unknown_fields_and_discriminators_are_forward_decodable(): value = job_result("remote_function_job.json") value["future_version_metadata"] = {"retention_class": "catalog"} - value["runtime"] = {"kind": "wasm", "module_digest": "sha256:wasm"} + value["image"]["descriptor"]["future_interface"] = {"kind": "wasm"} value["signature"]["output"]["kind"] = "future_output_shape" version = FunctionVersion.from_json(json.dumps(value)) - assert version.runtime.kind == "wasm" - assert version.runtime.python_version is None - assert version.runtime.environment is None - assert json.loads(version.to_canonical_json())["runtime"] == {"kind": "wasm"} + assert version.image.descriptor["future_interface"] == {"kind": "wasm"} + assert json.loads(version.to_canonical_json())["image"]["descriptor"][ + "future_interface" + ] == {"kind": "wasm"} assert version.signature.output.kind == "future_output_shape" @@ -222,7 +251,7 @@ def test_function_application_uses_rename_columns_only(): def test_binding_and_refresh_result_keep_stable_remote_fields(): binding = FunctionBinding.from_json(fixture("remote_function_binding.json")) - assert binding.function.version == "fv_01K3TEXT" + assert binding.function.version == "1" assert [output.output_ordinal for output in binding.outputs] == [0, 1] assert binding.input_schema is not None assert binding.output_schema is not None @@ -276,6 +305,31 @@ def test_refresh_result_rejects_non_u64_values(field): RefreshColumnResult.from_json(json.dumps(value)) +def test_canonical_client_values_carry_bindings_and_no_credentials(): + """A binding names a Secret; the credential behind it has no client field.""" + version = FunctionVersion.from_json( + json.dumps(job_result("remote_function_job.json")) + ) + canonical = json.loads(version.to_canonical_json()) + assert canonical["secret_bindings"] == [ + {"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}} + ] + assert_no_secret_values(canonical) + + +def test_a_version_without_bindings_omits_the_field_in_both_directions(): + """A Function that binds nothing carries no ``secret_bindings`` key. + + Absent decodes as an empty list, and an empty list serializes back to + absent. + """ + value = job_result("remote_function_job.json") + del value["secret_bindings"] + version = FunctionVersion.from_json(json.dumps(value)) + assert list(version.secret_bindings) == [] + assert "secret_bindings" not in json.loads(version.to_canonical_json()) + + class _FunctionDeclarationInner: def __init__(self): self.calls = [] @@ -339,7 +393,16 @@ def test_rename_requires_named_struct_and_keeps_partial_mapping_immutable(): scalar = FunctionApplication.from_json( json.dumps( { - "function": {"name": "embed", "version": "fv_exact"}, + "function": { + "name": "embed", + "version": "1", + "object_id": "fixture", + "location": "memory:///fixture", + "manifest_digest": ( + "sha256:" + "7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6" + ), + }, "inputs": [], "output": { "kind": "scalar", diff --git a/python/python/tests/test_first_class_function_slice2.py b/python/python/tests/test_first_class_function_slice2.py index 7ce6b6b91..1cd8d4cf5 100644 --- a/python/python/tests/test_first_class_function_slice2.py +++ b/python/python/tests/test_first_class_function_slice2.py @@ -11,15 +11,28 @@ import types from datetime import date import http.server import json +import os from pathlib import Path +import subprocess +import sys import threading +import urllib.parse from typing import Optional import pyarrow as pa import pytest import lancedb -from lancedb.functions import UdfDefinition, udf +from lancedb.functions import ( + PythonRuntimeSpec, + SecretBinding, + SecretReference, + UdfDefinition, + _canonical_arrow_type, + _GRAMMAR_PRIMITIVES, + udf, +) +from lancedb.secrets import EnvVarSecret THRESHOLD = 20 _CACHE = None @@ -36,6 +49,11 @@ FIXTURES = ( ) +FUNCTION_VERSION = json.loads( + (FIXTURES / "remote_function_version.canonical.json").read_text() +)["version"] + + @udf( pip=["numpy>=2"], env={"MODE": "test"}, @@ -45,6 +63,15 @@ def normalize_score(value: float) -> float: return value / 100.0 +@udf( + pip=["openai==3.7.0"], + env={"MODE": "test"}, + python_version="3.12", +) +def analyze_caption(caption: str) -> str: + return caption.strip() + + def test_scalar_udf_matches_shared_registration_golden_and_remains_callable(): assert isinstance(normalize_score, UdfDefinition) assert normalize_score(25.0) == 0.25 @@ -61,6 +88,360 @@ def test_scalar_udf_matches_shared_registration_golden_and_remains_callable(): } +def test_secret_bound_udf_matches_its_shared_registration_golden(): + assert analyze_caption(" hello ") == "hello" + bound = analyze_caption.bind_secrets( + [EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")] + ) + assert ( + bound.to_canonical_json() + == (FIXTURES / "remote_function_secret_registration_request.canonical.json") + .read_text() + .strip() + ) + + +def test_a_namespaced_binding_records_the_path_and_the_name(): + """A binding names the parts, so nothing has to be parsed back out. + + A root binding carries no path at all: the field is absent rather than an + empty list, so a binding states a namespace only when it has one. + """ + root = EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY") + assert root.secret_namespace_path == [] + + nested = EnvVarSecret( + secret_name="openai-prod", + env_variable="OPENAI_API_KEY", + secret_namespace_path=["prod", "vision"], + ) + assert nested.secret_namespace_path == ["prod", "vision"] + assert nested != root + + bound = analyze_caption.bind_secrets([nested]) + assert list(bound.secret_bindings) == [ + SecretBinding( + kind="env", + variable="OPENAI_API_KEY", + secret_ref=SecretReference( + name="openai-prod", namespace_path=("prod", "vision") + ), + ) + ] + + at_root = analyze_caption.bind_secrets([root]) + assert list(at_root.secret_bindings) == [ + SecretBinding( + kind="env", + variable="OPENAI_API_KEY", + secret_ref=SecretReference(name="openai-prod"), + ) + ] + # A root binding carries no path at all on the wire. + canonical = json.loads(at_root.to_canonical_json()) + assert canonical["secret_bindings"] == [ + { + "kind": "env", + "variable": "OPENAI_API_KEY", + "secret_ref": {"name": "openai-prod"}, + } + ] + + +def test_a_namespace_path_is_validated_locally(): + # The charset is the service's, not a delimiter's: a reference is never + # joined, so a segment cannot make anything parse two ways. + with pytest.raises(ValueError): + EnvVarSecret( + secret_name="openai-prod", + env_variable="K", + secret_namespace_path=["with$delim"], + ) + with pytest.raises(ValueError): + EnvVarSecret( + secret_name="openai-prod", env_variable="K", secret_namespace_path=["a/b"] + ) + # A bare string is a plausible mistake with the wrong meaning. + with pytest.raises(TypeError): + EnvVarSecret( + secret_name="openai-prod", env_variable="K", secret_namespace_path="prod" + ) + + +def test_an_unbound_request_carries_no_binding_at_all(): + """Binding is a registration-time decision, so the definition holds none. + + The decorator declares nothing about secrets, which is what makes the PRD's + claim true: a Function's source and its registration request are identical + whether or not a credential is later bound to it. + """ + unbound = json.loads(analyze_caption.registration_request.to_canonical_json()) + assert "secret_bindings" not in unbound + assert "OPENAI_API_KEY" not in json.dumps(unbound) + + +def test_binding_a_secret_leaves_the_packaged_artifact_untouched(): + """The artifact is source bytes and nothing else, with or without secrets.""" + bound = analyze_caption.bind_secrets( + [EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")] + ) + assert bound.artifact == analyze_caption.registration_request.artifact + assert bound.artifact.digest == analyze_caption.registration_request.artifact.digest + + +def test_a_function_declaring_no_secret_is_registered_exactly_as_before(): + """The compatibility claim: nothing about the no-secret path moves.""" + assert ( + normalize_score.bind_secrets(None).to_canonical_json() + == normalize_score.registration_request.to_canonical_json() + ) + assert ( + "secret_bindings" + not in normalize_score.registration_request.to_canonical_json() + ) + + +def test_a_function_binds_each_variable_once(): + with pytest.raises(ValueError, match="binds each environment variable once"): + analyze_caption.bind_secrets( + [ + EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY"), + EnvVarSecret( + secret_name="openai-staging", env_variable="OPENAI_API_KEY" + ), + ] + ) + + +def test_bindings_may_not_collide_with_plain_configuration(): + """`env` is stored with the Function; a Secret is not. Refuse, do not pick.""" + with pytest.raises(ValueError, match="must be disjoint"): + analyze_caption.bind_secrets( + [EnvVarSecret(secret_name="mode-prod", env_variable="MODE")] + ) + + +def test_a_binding_envelope_reaches_the_service_for_it_to_judge(): + """Binding rules are the service's: it owns the runtime the names land in. + + The client sends what it was given, so a rule it duplicated could disagree + with the service's without either side noticing. What is checked here is + that the envelope arrives intact -- the shape the service judges is the + shape the caller wrote. + """ + with _mock_remote_function_catalog() as (host, state): + db = lancedb.connect( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + bindings = [ + EnvVarSecret(secret_name=f"secret-{index}", env_variable=f"TOKEN_{index}") + for index in range(17) + ] + db.create_function(normalize_score, secrets=bindings) + + sent = state["requests"][0][1] + assert len(sent["secret_bindings"]) == 17 + assert { + "kind": "env", + "variable": "TOKEN_0", + "secret_ref": {"name": "secret-0"}, + } in sent["secret_bindings"] + + +_SECRET_DEBUG_LOG_SOURCE = """ +import http.server +import json +import threading + +import lancedb + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + payload = json.dumps({}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +server = http.server.ThreadingHTTPServer(("localhost", 0), Handler) +threading.Thread(target=server.serve_forever, daemon=True).start() +try: + db = lancedb.connect( + "db://dev", + api_key="API_KEY_SENTINEL", + host_override="http://localhost:%d" % server.server_address[1], + client_config={"retry_config": {"retries": 0}}, + ) + db.create_secret("openai-prod", "SECRET_VALUE_SENTINEL") +finally: + server.shutdown() +""" + + +def test_a_credential_never_reaches_a_debug_log(tmp_path): + """The logger sees the serialized body, so no value-side redaction reaches it. + + Runs in a subprocess because the Rust logger reads ``LANCEDB_LOG`` once, at + import. + """ + script = tmp_path / "write_secret.py" + script.write_text(_SECRET_DEBUG_LOG_SOURCE) + + result = subprocess.run( + [sys.executable, str(script)], + check=True, + capture_output=True, + text=True, + env={**os.environ, "LANCEDB_LOG": "debug"}, + ) + output = result.stdout + result.stderr + + # Without this the test passes when debug logging is simply off. + assert "Sending request_id=" in output, output + assert "SECRET_VALUE_SENTINEL" not in output + assert "API_KEY_SENTINEL" not in output + + +def test_a_credential_value_is_rejected_in_the_binding_position(): + """The one mistake the typed binding exists to stop.""" + with pytest.raises(TypeError, match="EnvVarSecret"): + analyze_caption.bind_secrets(["sk-live-0001"]) + + +@pytest.mark.parametrize( + ("secret", "variable", "message"), + [ + ("openai-prod", "not-a-var", "invalid environment variable name"), + ("openai-prod", "API-TOKEN", "invalid environment variable name"), + ("not a name", "API_TOKEN", "invalid Secret name"), + ("openai$prod", "API_TOKEN", "invalid Secret name"), + ], +) +def test_a_binding_validates_both_names_locally(secret, variable, message): + with pytest.raises(ValueError, match=message): + EnvVarSecret(secret_name=secret, env_variable=variable) + + +def test_a_secret_name_admits_what_a_namespace_name_does(): + """A Secret has to be nameable wherever a namespace already is. + + LanceDB namespace and table names are `[A-Za-z0-9_.-]` with no rule about + which character comes first, so a name may lead with `_`, `-` or `.`. + Anything narrower here would leave Secrets unaddressable inside namespaces + that already exist -- the reason periods are admitted is the reason the + edges are too. + """ + for name in ["openai.prod.v1", ".hidden", "_internal", "-lead", "trailing."]: + binding = EnvVarSecret(secret_name=name, env_variable="OPENAI_API_KEY") + assert binding.secret_name == name + + for name in ["", "with/slash", "with$delimiter", "a" * 256]: + with pytest.raises(ValueError, match="invalid Secret name"): + EnvVarSecret(secret_name=name, env_variable="OPENAI_API_KEY") + + # A namespace segment follows the same rule, and LanceDB already admits + # these shapes as namespace names -- so a Secret is addressable inside one. + for segment in [".hidden", "_internal", "-lead", "trailing."]: + binding = EnvVarSecret( + secret_name="openai-prod", + env_variable="OPENAI_API_KEY", + secret_namespace_path=[segment], + ) + assert binding.secret_namespace_path == [segment] + + for segment in ["", "with/slash", "with$delimiter"]: + with pytest.raises(ValueError, match="invalid namespace path segment"): + EnvVarSecret( + secret_name="openai-prod", + env_variable="OPENAI_API_KEY", + secret_namespace_path=[segment], + ) + + +def _main_udf_source( + *, threshold: int = 20, input_annotation: str = "int", comparison: str = ">=" +) -> str: + return ( + "from __future__ import annotations\n" + "from lancedb.functions import udf\n" + f"THRESHOLD = {threshold}\n" + "\n" + "@udf\n" + f"def label(value: {input_annotation}) -> str:\n" + f" return 'big' if value {comparison} THRESHOLD else 'small'\n" + "\n" + "assert label.__module__ == '__main__'\n" + "print(label.registration_request.to_canonical_json())\n" + ) + + +def _run_main_udf(path: Path, source: str) -> dict: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source) + result = subprocess.run( + [sys.executable, str(path)], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def test_main_udf_registration_identity_is_stable_across_processes_and_paths( + tmp_path, +): + source = _main_udf_source() + original_path = tmp_path / "original" / "job.py" + moved_path = tmp_path / "moved" / "renamed_job.py" + + original_runs = [_run_main_udf(original_path, source) for _ in range(2)] + moved_run = _run_main_udf(moved_path, source) + + assert len({run["artifact"]["digest"] for run in [*original_runs, moved_run]}) == 1 + assert all( + run["signature"] == original_runs[0]["signature"] + for run in [original_runs[1], moved_run] + ) + assert original_runs[0] == original_runs[1] == moved_run + + body_change = _run_main_udf( + tmp_path / "changes" / "body.py", _main_udf_source(comparison=">") + ) + global_change = _run_main_udf( + tmp_path / "changes" / "global.py", _main_udf_source(threshold=21) + ) + annotation_change = _run_main_udf( + tmp_path / "changes" / "annotation.py", + _main_udf_source(input_annotation="float"), + ) + + baseline = original_runs[0] + assert baseline["signature"] == body_change["signature"] + assert baseline["signature"] == global_change["signature"] + assert baseline["signature"] != annotation_change["signature"] + assert ( + len( + { + baseline["artifact"]["digest"], + body_change["artifact"]["digest"], + global_change["artifact"]["digest"], + annotation_change["artifact"]["digest"], + } + ) + == 4 + ) + + def _run_packaged(definition, *args): """Execute the shipped artifact in a fresh namespace, as a worker would.""" source = base64.b64decode(definition.registration_request.artifact.content.data) @@ -89,6 +470,58 @@ def test_udf_conda_environment(): udf(name="channels", conda_channels=["conda-forge"])(lambda value: value) +def test_udf_gpu_marker_uses_gpu_runtime(): + @udf(pip=["cupy-cuda12x"], gpu=True) + def double_on_gpu(value: int) -> int: + return value * 2 + + request = json.loads(double_on_gpu.registration_request.to_canonical_json()) + assert request["runtime"]["kind"] == "python_v2" + assert request["runtime"]["gpu"] is True + + @udf(pip=["pyarrow"]) + def cpu_function(value: int) -> int: + return value + + cpu_runtime = json.loads(cpu_function.registration_request.to_canonical_json())[ + "runtime" + ] + assert cpu_runtime["kind"] == "python" + assert "gpu" not in cpu_runtime + + def identity(value: int) -> int: + return value + + for invalid in [None, 0, 1, -1, 1.5, "", "true", "1", "H100"]: + with pytest.raises(ValueError, match="gpu must be a boolean"): + udf(name="invalid_gpu", gpu=invalid)(identity) + + base_runtime = { + "kind": "python_v2", + "python_version": "3.12", + "environment": {"kind": "pip"}, + } + runtime = PythonRuntimeSpec.model_validate({**base_runtime, "gpu": True}) + assert runtime.gpu is True + for invalid in [False, 1, 0, "", "true", "1", "H100"]: + with pytest.raises(ValueError, match="runtime.gpu must be true"): + PythonRuntimeSpec.model_validate({**base_runtime, "gpu": invalid}) + + +def test_unknown_runtime_discards_payload_before_known_field_validation(): + for payload in [ + {"kind": "python_v3", "gpu": {"model": "H100"}}, + {"kind": "python_v3", "resources": []}, + { + "kind": "python_v3", + "environment": {"kind": []}, + "python_version": 3.15, + }, + ]: + runtime = PythonRuntimeSpec.model_validate(payload) + assert runtime.to_canonical_json() == '{"kind":"python_v3"}' + + def test_udf_packages_attribute_access_and_body_imports(): @udf def word_norm(body: str) -> float: @@ -168,9 +601,7 @@ def test_udf_resolves_module_globals_before_builtins(tmp_path): udf(module.uses_callable_shadow) -def test_canonical_arrow_type_is_exactly_the_grammar(): - from lancedb.functions import _GRAMMAR_PRIMITIVES, _canonical_arrow_type - +def test_canonical_arrow_type_prefers_the_compact_grammar(): golden = json.loads( ( Path(__file__).parents[3] @@ -181,14 +612,19 @@ def test_canonical_arrow_type_is_exactly_the_grammar(): case["arrow_type"] for case in golden["valid"] if "<" not in case["arrow_type"] ] assert [name for _, name in _GRAMMAR_PRIMITIVES] == primitives + assert _canonical_arrow_type(pa.list_(pa.field("item", pa.float32(), False))) == ( + "list" + ) + assert ( + _canonical_arrow_type(pa.large_list(pa.field("item", pa.float32(), False))) + == "large_list" + ) for outside in [ pa.timestamp("us"), pa.decimal128(10, 2), - pa.large_string(), pa.large_binary(), pa.binary(4), pa.duration("s"), - pa.struct([pa.field("a", pa.int32())]), pa.list_(pa.float32(), 0), pa.list_(pa.timestamp("us")), ]: @@ -378,14 +814,27 @@ def test_udf_recursion_versus_a_rebound_module_name(tmp_path): udf(raw_fact) -def test_canonical_arrow_type_rejects_unrepresentable_list_children(): - from lancedb.functions import _canonical_arrow_type - +def test_canonical_arrow_type_uses_exact_json_for_list_child_properties(): + nullable = pa.list_(pa.float32()) + assert json.loads(_canonical_arrow_type(nullable)) == { + "type": "list", + "fields": [ + { + "name": "item", + "nullable": True, + "type": {"type": "float32"}, + } + ], + } + named = pa.list_(pa.field("custom", pa.float32(), nullable=False)) + assert json.loads(_canonical_arrow_type(named))["fields"][0]["name"] == "custom" for outside in [ - pa.list_(pa.float32()), # pyarrow default: nullable child - pa.list_(pa.field("custom", pa.float32(), nullable=False)), pa.list_(pa.field("item", pa.float32(), nullable=False, metadata={"k": "v"})), pa.list_(pa.field("item", pa.float32(), nullable=False), 0), + pa.list_( + pa.field("item", pa.float32(), nullable=False, metadata={"k": "v"}), 3 + ), + pa.list_(pa.field("custom", pa.float32(), nullable=False), 3), ]: with pytest.raises(TypeError, match="unsupported Arrow type"): _canonical_arrow_type(outside) @@ -395,6 +844,29 @@ def test_canonical_arrow_type_rejects_unrepresentable_list_children(): ) == "fixed_size_list" ) + fixed = json.loads(_canonical_arrow_type(pa.list_(pa.float32(), 3))) + assert fixed == { + "type": "fixed_size_list", + "fields": [ + { + "name": "item", + "nullable": True, + "type": {"type": "float32"}, + } + ], + "length": 3, + } + large = json.loads(_canonical_arrow_type(pa.large_list(pa.float32()))) + assert large["type"] == "large_list" + assert large["fields"][0]["nullable"] is True + + for invalid_struct in [ + pa.struct([]), + pa.struct([pa.field("a", pa.int32()), pa.field("a", pa.int64())]), + pa.struct([pa.field("", pa.int32())]), + ]: + with pytest.raises(TypeError, match="unsupported Arrow type"): + _canonical_arrow_type(invalid_struct) def _calls_missing(value: int) -> int: @@ -432,6 +904,7 @@ def _arrow_type_from_golden(spec: dict) -> pa.DataType: "null": pa.null(), "bool": pa.bool_(), "utf8": pa.string(), + "large_utf8": pa.large_string(), "binary": pa.binary(), "float16": pa.float16(), "float32": pa.float32(), @@ -448,8 +921,6 @@ def test_arrow_type_grammar_matches_the_shared_golden(): / "rust/lancedb/tests/fixtures/first_class_functions/v1/arrow_types.json" ).read_text() ) - from lancedb.functions import _canonical_arrow_type - emitted = { case["arrow_type"]: _canonical_arrow_type(_arrow_type_from_golden(case["json"])) for case in golden["valid"] @@ -482,6 +953,432 @@ def test_explicit_arrow_schema_is_deterministic(): assert signature.output.nullable is False +def test_blob_fields_use_the_scalar_function_semantic_type(): + @udf( + input_schema=pa.schema([lancedb.blob("image", nullable=False)]), + output_schema=lancedb.blob("result", nullable=False), + ) + def copy_blob(image): + return image + + signature = copy_blob.registration_request.signature + assert signature.inputs[0].arrow_type == "blob_v2" + assert signature.output.kind == "scalar" + assert signature.output.arrow_type == "blob_v2" + + +def test_whole_named_struct_function_can_include_a_blob_result_field(): + @udf( + input_schema=pa.schema([lancedb.blob("image", nullable=False)]), + output_schema=pa.field( + "payload", + pa.struct( + [ + pa.field("mime_type", pa.string(), nullable=False), + lancedb.blob("image", nullable=False), + ] + ), + nullable=False, + ), + ) + def inspect_blob(image): + return {"mime_type": "image/png", "image": image} + + output = inspect_blob.registration_request.signature.output + assert output.kind == "named_struct" + assert [(field.name, field.arrow_type) for field in output.fields] == [ + ("mime_type", "utf8"), + ("image", "blob_v2"), + ] + + +def test_struct_blob_signature_fields_preserve_exact_metadata_and_nullability(): + nested_input = pa.field( + "payload", + pa.struct( + [ + pa.field("mime_type", pa.string(), nullable=False), + pa.field( + "nested", + pa.struct([lancedb.blob("image", nullable=True)]), + nullable=True, + ), + ] + ), + nullable=True, + ) + nested_output = pa.field( + "result", + pa.struct( + [ + pa.field("mime_type", pa.string(), nullable=False), + pa.field( + "nested", + pa.struct([lancedb.blob("image", nullable=True)]), + nullable=False, + ), + ] + ), + nullable=False, + ) + + @udf(input_schema=pa.schema([nested_input]), output_schema=nested_output) + def copy_payload(payload): + return payload + + signature = copy_payload.registration_request.signature + input_type = json.loads(signature.inputs[0].arrow_type) + assert input_type["fields"][1]["nullable"] is True + input_blob = input_type["fields"][1]["type"]["fields"][0] + assert input_blob["nullable"] is True + assert input_blob["metadata"] == {"ARROW:extension:name": "lance.blob.v2"} + + assert signature.output.kind == "named_struct" + nested_result = next( + field for field in signature.output.fields if field.name == "nested" + ) + output_type = json.loads(nested_result.arrow_type) + output_blob = output_type["fields"][0] + assert output_blob["nullable"] is True + assert output_blob["metadata"] == {"ARROW:extension:name": "lance.blob.v2"} + + +def test_struct_blob_signature_supports_multiple_struct_levels(): + recursive = pa.field( + "value", + pa.struct( + [ + pa.field( + "level_1", + pa.struct( + [ + pa.field( + "level_2", + pa.struct([lancedb.blob("image", nullable=False)]), + nullable=False, + ) + ] + ), + nullable=False, + ) + ] + ), + nullable=False, + ) + + @udf( + input_schema=pa.schema([recursive]), + output_schema=pa.field("size", pa.int64(), nullable=False), + ) + def blob_size(value): + return len(value["level_1"]["level_2"]["image"]) + + encoded = json.loads(blob_size.registration_request.signature.inputs[0].arrow_type) + blob = encoded["fields"][0]["type"]["fields"][0]["type"]["fields"][0] + assert blob["metadata"]["ARROW:extension:name"] == "lance.blob.v2" + + +@pytest.mark.parametrize( + "data_type", + [ + pa.list_(lancedb.blob("item", nullable=False)), + pa.large_list(lancedb.blob("item", nullable=False)), + pa.list_(lancedb.blob("item", nullable=False), 2), + pa.map_(pa.string(), lancedb.blob("value", nullable=False).type), + ], +) +def test_blob_signature_rejects_collection_ancestors(data_type): + with pytest.raises( + TypeError, + match="Blob v2 fields nested under collection types are not supported", + ): + + @udf( + input_schema=pa.schema([pa.field("value", data_type, nullable=False)]), + output_schema=pa.field("size", pa.int64(), nullable=False), + ) + def blob_size(value): + return len(value) + + +def test_blob_signature_rejects_collection_below_a_struct(): + nested = pa.field( + "value", + pa.struct( + [ + pa.field( + "images", + pa.list_(lancedb.blob("item", nullable=False)), + nullable=False, + ) + ] + ), + nullable=False, + ) + with pytest.raises( + TypeError, + match="Blob v2 fields nested under collection types are not supported", + ): + + @udf( + input_schema=pa.schema([nested]), + output_schema=pa.field("size", pa.int64(), nullable=False), + ) + def blob_size(value): + return len(value["images"]) + + +def test_named_struct_function_can_include_a_blob_result_field(): + @udf( + input_schema=pa.schema([lancedb.blob("image", nullable=False)]), + output_schema=pa.schema( + [ + lancedb.blob("thumbnail", nullable=False), + pa.field("width", pa.int32(), nullable=False), + ] + ), + ) + def inspect_blob(image): + return {"thumbnail": image, "width": 1} + + output = inspect_blob.registration_request.signature.output + assert output.kind == "named_struct" + assert [(field.name, field.arrow_type) for field in output.fields] == [ + ("thumbnail", "blob_v2"), + ("width", "int32"), + ] + + +def test_named_struct_function_preserves_nullable_result_fields(): + @udf( + input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]), + output_schema=pa.schema( + [ + pa.field("result", pa.int64(), nullable=True), + pa.field("failure_code", pa.int32(), nullable=False), + ] + ), + ) + def nullable_result(value): + return {"result": value, "failure_code": 0} + + output = nullable_result.registration_request.signature.output + assert [(field.name, field.nullable) for field in output.fields] == [ + ("result", True), + ("failure_code", False), + ] + + @udf( + input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]), + output_schema=pa.schema( + [ + pa.field("result", pa.int64(), nullable=True), + pa.field("failure_code", pa.int32(), nullable=True), + ] + ), + ) + def all_nullable(value): + return {"result": value, "failure_code": None} + + assert all( + field.nullable + for field in all_nullable.registration_request.signature.output.fields + ) + + +def test_metadata_marked_blob_field_uses_the_semantic_type(): + extension = lancedb.blob("image", nullable=False).type + storage = ( + extension.storage_type if isinstance(extension, pa.ExtensionType) else extension + ) + metadata_blob = pa.field( + "image", + storage, + nullable=False, + metadata={"ARROW:extension:name": "lance.blob.v2"}, + ) + + @udf( + input_schema=pa.schema([metadata_blob]), + output_schema=pa.field("size", pa.int64(), nullable=False), + ) + def blob_size(image): + return len(image) + + assert blob_size.registration_request.signature.inputs[0].arrow_type == "blob_v2" + + +def test_blob_marker_rejects_invalid_storage_layout(): + malformed = pa.field( + "image", + pa.int64(), + nullable=False, + metadata={"ARROW:extension:name": "lance.blob.v2"}, + ) + + with pytest.raises(TypeError, match="requires a supported Blob storage layout"): + + @udf( + input_schema=pa.schema([malformed]), + output_schema=pa.field("size", pa.int64(), nullable=False), + ) + def blob_size(image): + return len(image) + + +def test_nested_non_blob_extension_is_not_silently_unwrapped(): + class TestExtension(pa.ExtensionType): + def __init__(self): + super().__init__(pa.int64(), "test.function.extension") + + def __arrow_ext_serialize__(self): + return b"" + + @classmethod + def __arrow_ext_deserialize__(cls, storage_type, serialized): + return cls() + + nested = pa.field( + "value", + pa.struct([pa.field("extended", TestExtension(), nullable=False)]), + nullable=False, + ) + with pytest.raises(TypeError, match="unsupported Arrow type"): + + @udf( + input_schema=pa.schema([nested]), + output_schema=pa.field("result", pa.int64(), nullable=False), + ) + def extension_value(value): + return value["extended"] + + +def test_explicit_large_utf8_schemas_use_the_canonical_function_name(): + input_schema = pa.schema([pa.field("text", pa.large_string(), nullable=True)]) + output_schema = pa.field("result", pa.large_string(), nullable=False) + + @udf(input_schema=input_schema, output_schema=output_schema) + def preserve(text): + return text + + signature = preserve.registration_request.signature + assert signature.inputs[0].arrow_type == "large_utf8" + assert signature.inputs[0].nullable is True + assert signature.output.arrow_type == "large_utf8" + assert signature.output.nullable is False + + nested = pa.struct([pa.field("text", pa.large_string(), nullable=True)]) + assert json.loads(_canonical_arrow_type(nested)) == { + "type": "struct", + "fields": [ + { + "name": "text", + "nullable": True, + "type": {"type": "large_utf8"}, + } + ], + } + + +def test_nested_struct_output_uses_canonical_exact_json(): + token = pa.struct( + [ + pa.field("position", pa.int32(), nullable=False), + pa.field("value", pa.string(), nullable=False), + pa.field("length", pa.int32(), nullable=False), + ] + ) + analysis = pa.struct( + [ + pa.field("normalized_text", pa.string(), nullable=False), + pa.field("has_content", pa.bool_(), nullable=False), + pa.field( + "metrics", + pa.struct( + [ + pa.field("character_count", pa.int64(), nullable=False), + pa.field("word_count", pa.int32(), nullable=False), + pa.field("average_word_length", pa.float64(), nullable=False), + ] + ), + nullable=False, + ), + pa.field( + "diagnostics", + pa.struct( + [ + pa.field("status", pa.string(), nullable=False), + pa.field( + "normalization", + pa.struct( + [ + pa.field("changed", pa.bool_(), nullable=False), + pa.field( + "original_length", pa.int64(), nullable=False + ), + ] + ), + nullable=False, + ), + ] + ), + nullable=False, + ), + pa.field( + "token_preview", + pa.list_(pa.field("item", token, nullable=False)), + nullable=False, + ), + ] + ) + + @udf( + input_schema=pa.schema([pa.field("text", pa.string(), nullable=False)]), + output_schema=pa.field("analysis", analysis, nullable=False), + ) + def analyze(text): + return {"normalized_text": text} + + output = analyze.registration_request.signature.output + assert output.kind == "named_struct" + assert [field.name for field in output.fields] == [ + "normalized_text", + "has_content", + "metrics", + "diagnostics", + "token_preview", + ] + metrics = json.loads(output.fields[2].arrow_type) + assert metrics == { + "type": "struct", + "fields": [ + { + "name": "character_count", + "nullable": False, + "type": {"type": "int64"}, + }, + { + "name": "word_count", + "nullable": False, + "type": {"type": "int32"}, + }, + { + "name": "average_word_length", + "nullable": False, + "type": {"type": "float64"}, + }, + ], + } + preview = json.loads(output.fields[4].arrow_type) + assert preview["type"] == "list" + assert preview["fields"][0]["type"]["type"] == "struct" + assert [field["name"] for field in preview["fields"][0]["type"]["fields"]] == [ + "position", + "value", + "length", + ] + + def test_annotation_and_explicit_schema_validation_fail_closed(): with pytest.raises(TypeError, match="missing Function annotations"): @@ -525,6 +1422,72 @@ def test_annotation_and_explicit_schema_validation_fail_closed(): def nullable_explicit(value): return value + for invalid_field in [ + pa.field("", pa.int32(), nullable=False), + pa.field("result", pa.int32(), nullable=False, metadata={"k": "v"}), + ]: + with pytest.raises(TypeError, match="unsupported Arrow type"): + + @udf( + input_schema=pa.schema([pa.field("value", pa.int64())]), + output_schema=pa.schema([invalid_field]), + ) + def invalid_explicit_field(value): + return value + + with pytest.raises(TypeError, match="unsupported Arrow type"): + + @udf( + input_schema=pa.schema( + [pa.field("value", pa.int64(), metadata={"k": "v"})] + ), + output_schema=pa.int64(), + ) + def input_field_metadata(value): + return value + + with pytest.raises(TypeError, match="unsupported Arrow type"): + + @udf( + input_schema=pa.schema([pa.field("value", pa.int64())]), + output_schema=pa.field( + "result", pa.int64(), nullable=False, metadata={"k": "v"} + ), + ) + def scalar_output_field_metadata(value): + return value + + struct_type = pa.struct([pa.field("value", pa.int64(), nullable=False)]) + with pytest.raises(TypeError, match="unsupported Arrow type"): + + @udf( + input_schema=pa.schema([pa.field("value", pa.int64())]), + output_schema=pa.field( + "result", struct_type, nullable=False, metadata={"k": "v"} + ), + ) + def struct_output_field_metadata(value): + return {"value": value} + + for input_schema, output_schema in [ + ( + pa.schema([pa.field("value", pa.int64())], metadata={"k": "v"}), + pa.int64(), + ), + ( + pa.schema([pa.field("value", pa.int64())]), + pa.schema( + [pa.field("result", pa.int64(), nullable=False)], + metadata={"k": "v"}, + ), + ), + ]: + with pytest.raises(TypeError, match="schema metadata"): + + @udf(input_schema=input_schema, output_schema=output_schema) + def schema_metadata(value): + return value + def test_local_function_catalog_operations_are_not_supported(tmp_path): db = lancedb.connect(tmp_path) @@ -534,7 +1497,11 @@ def test_local_function_catalog_operations_are_not_supported(tmp_path): with pytest.raises(NotImplementedError, match=message): db.create_function_async(normalize_score) with pytest.raises(NotImplementedError, match=message): - db.get_function("normalize_score", version="fv_exact") + db.get_function("normalize_score", version=FUNCTION_VERSION) + with pytest.raises(NotImplementedError, match=message): + db.list_functions() + with pytest.raises(NotImplementedError, match=message): + db.drop_function("normalize_score", version=FUNCTION_VERSION) @contextlib.contextmanager @@ -545,23 +1512,47 @@ def _mock_remote_function_catalog(): def log_message(self, *args): pass + def _write_response(self, status, response): + encoded = json.dumps(response).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + def do_POST(self): length = int(self.headers.get("Content-Length", "0")) body = json.loads(self.rfile.read(length) or b"{}") state["requests"].append((self.path, body)) status = 200 - if self.path == "/v1/functions/create": + # `{id}` is the Function name, so match on the shape rather than on + # one name: these tests register more than one Function. + parts = self.path.strip("/").split("/") + function_action = ( + (urllib.parse.unquote(parts[2]), parts[3]) + if len(parts) == 4 and parts[0] == "v1" and parts[1] == "function" + else (None, None) + ) + secret_action = ( + (urllib.parse.unquote(parts[2]), parts[3]) + if len(parts) == 4 and parts[0] == "v1" and parts[1] == "secret" + else (None, None) + ) + if function_action[1] == "create": state["version"] = { - "name": body["name"], - "version": "fv_exact", - "artifact": { - key: body["artifact"][key] - for key in ("kind", "digest", "entrypoint") - }, + "name": "normalize_score", + "version": FUNCTION_VERSION, + "object_id": "fixture", + "location": "memory:///fixture", + "metadata": {}, + "disabled": False, + "image": json.loads( + ( + FIXTURES / "remote_function_version.canonical.json" + ).read_text() + )["image"], "signature": body["signature"], - "runtime": body["runtime"], - "runtime_digest": "sha256:runtime", - "environment_digest": "sha256:environment", + "secret_bindings": body.get("secret_bindings", []), "created_at": "2026-08-21T00:00:00Z", } response = {"job_id": "job-register"} @@ -574,21 +1565,62 @@ def _mock_remote_function_catalog(): "job_state": "DONE", "result": state["version"], } - elif self.path == "/v1/functions/describe": - assert body == { - "name": "normalize_score", - "version": "fv_exact", - } + elif self.path == "/v1/function/normalize_score/describe": + assert body == {"version": FUNCTION_VERSION} response = state["version"] + elif self.path == "/v1/function/normalize_score/drop": + assert body == {"version": FUNCTION_VERSION} + response = {"dropped": True} + elif secret_action[1] in ("create", "alter"): + # The Secret is the path identifier, so the body is the value. + assert set(body) == {"value"} + assert secret_action[0] == "openai-prod" + response = {} + elif secret_action[1] == "drop": + assert secret_action[0] == "openai-prod" + assert body == {} + response = {} else: status = 404 response = {"error": "not found"} - encoded = json.dumps(response).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) + self._write_response(status, response) + + def do_GET(self): + url = urllib.parse.urlsplit(self.path) + query = { + key: values[-1] + for key, values in urllib.parse.parse_qs(url.query).items() + } + state["requests"].append((url.path, query)) + if url.path == "/v1/namespace/$/secret/list": + if "page_token" not in query: + self._write_response( + 200, + {"secrets": [{"name": "openai-prod"}], "page_token": "next"}, + ) + else: + assert query["page_token"] == "next" + self._write_response(200, {"secrets": [{"name": "hf-prod"}]}) + return + if url.path != "/v1/namespace/$/function/list": + self._write_response(404, {"error": "not found"}) + return + assert query["include_definition"] == "true" + if "page_token" not in query: + response = { + "functions": [ + { + "name": "normalize_score", + "version": FUNCTION_VERSION, + "definition": state["version"], + } + ], + "page_token": "next", + } + else: + assert query["page_token"] == "next" + response = {"functions": []} + self._write_response(200, response) with http.server.HTTPServer(("localhost", 0), Handler) as server: thread = threading.Thread(target=server.serve_forever) @@ -615,11 +1647,99 @@ def test_remote_registration_job_and_exact_version_reopen_round_trip(): assert created == reopened assert reopened.name == "normalize_score" - assert reopened.version == "fv_exact" + assert reopened.version == FUNCTION_VERSION create_request = state["requests"][0][1] - assert create_request == json.loads( + expected_request = json.loads( normalize_score.registration_request.to_canonical_json() ) + expected_request.pop("name") + assert create_request == expected_request + + +def test_remote_registration_sends_bindings_and_never_a_credential(): + with _mock_remote_function_catalog() as (host, state): + db = lancedb.connect( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + created = db.create_function( + analyze_caption, + secrets=[ + EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY") + ], + ) + + assert list(created.secret_bindings) == [ + SecretBinding( + kind="env", + variable="OPENAI_API_KEY", + secret_ref=SecretReference(name="openai-prod"), + ) + ] + path, create_request = state["requests"][0] + assert path == "/v1/function/analyze_caption/create" + assert create_request["secret_bindings"] == [ + { + "kind": "env", + "variable": "OPENAI_API_KEY", + "secret_ref": {"name": "openai-prod"}, + } + ] + # The request names a Secret and carries nothing that could be one. The + # Function's own name is the path identifier rather than a body field, so + # it is the one key the body does not repeat. + expected = json.loads( + analyze_caption.bind_secrets( + [EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")] + ).to_canonical_json() + ) + assert expected.pop("name") == "analyze_caption" + assert create_request == expected + + +def test_remote_secret_verbs_round_trip(): + with _mock_remote_function_catalog() as (host, state): + db = lancedb.connect( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + assert db.create_secret("openai-prod", "sk-live-0001") is None + assert db.alter_secret("openai-prod", "sk-live-0002") is None + assert db.list_secrets() == ["openai-prod", "hf-prod"] + assert db.drop_secret("openai-prod") is None + + routes = [path for path, _ in state["requests"]] + assert routes == [ + "/v1/secret/openai-prod/create", + "/v1/secret/openai-prod/alter", + "/v1/namespace/$/secret/list", + "/v1/namespace/$/secret/list", + "/v1/secret/openai-prod/drop", + ] + # The Secret is the path identifier, so the body is the value alone. + assert state["requests"][0][1] == {"value": "sk-live-0001"} + # Listing is a GET: the first page asks for nothing, the second resumes on + # the token the server handed back, and neither carries a body. + assert state["requests"][2][1] == {} + assert state["requests"][3][1] == {"page_token": "next"} + + +def test_building_a_binding_contacts_no_server(): + """A binding is a local value: it says nothing about whether the Secret exists. + + Existence is the server's answer at registration, where a mistyped name is a + clear error rather than a client-side check that was already stale. + """ + with _mock_remote_function_catalog() as (_host, state): + binding = EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY") + assert binding.secret_name == "openai-prod" + assert binding.env_variable == "OPENAI_API_KEY" + + assert state["requests"] == [] def test_blocking_remote_registration_returns_function_version(): @@ -633,8 +1753,90 @@ def test_blocking_remote_registration_returns_function_version(): created = db.create_function(normalize_score) assert created.name == "normalize_score" - assert created.version == "fv_exact" + assert created.version == FUNCTION_VERSION assert [path for path, _ in state["requests"]] == [ - "/v1/functions/create", + "/v1/function/normalize_score/create", "/v1/jobs/describe", ] + + +def test_remote_list_functions_paginates_and_returns_typed_versions(): + with _mock_remote_function_catalog() as (host, state): + db = lancedb.connect( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + created = db.create_function(normalize_score) + state["requests"].clear() + functions = db.list_functions() + + assert functions == [created] + assert state["requests"] == [ + ("/v1/namespace/$/function/list", {"include_definition": "true"}), + ( + "/v1/namespace/$/function/list", + {"include_definition": "true", "page_token": "next"}, + ), + ] + + +@pytest.mark.asyncio +async def test_async_remote_list_functions_returns_typed_versions(): + with _mock_remote_function_catalog() as (host, state): + db = await lancedb.connect_async( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + registration = await db.create_function_async(normalize_score) + created = await registration.wait() + state["requests"].clear() + functions = await db.list_functions() + + assert functions == [created] + assert [path for path, _ in state["requests"]] == [ + "/v1/namespace/$/function/list", + "/v1/namespace/$/function/list", + ] + + +def test_remote_drop_function_sends_exact_version(): + with _mock_remote_function_catalog() as (host, state): + db = lancedb.connect( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + assert db.drop_function("normalize_score", version=FUNCTION_VERSION) is True + + assert state["requests"] == [ + ( + "/v1/function/normalize_score/drop", + {"version": FUNCTION_VERSION}, + ) + ] + + +@pytest.mark.asyncio +async def test_async_remote_drop_function_sends_exact_version(): + with _mock_remote_function_catalog() as (host, state): + db = await lancedb.connect_async( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + assert ( + await db.drop_function("normalize_score", version=FUNCTION_VERSION) is True + ) + + assert state["requests"] == [ + ( + "/v1/function/normalize_score/drop", + {"version": FUNCTION_VERSION}, + ) + ] diff --git a/python/python/tests/test_fts.py b/python/python/tests/test_fts.py index e5129dd9c..e7b0a83ce 100644 --- a/python/python/tests/test_fts.py +++ b/python/python/tests/test_fts.py @@ -1011,8 +1011,13 @@ def test_fts_ngram(mem_db: DBConnection): assert set(r["text"] for r in results) == {"lance database", "lance is cool"} results = ( - table.search("nce", query_type="fts").limit(10).to_list() - ) # spellchecker:disable-line + table.search( + "nce", # spellchecker:disable-line + query_type="fts", + ) + .limit(10) + .to_list() + ) assert len(results) == 2 assert set(r["text"] for r in results) == {"lance database", "lance is cool"} @@ -1034,8 +1039,13 @@ def test_fts_ngram(mem_db: DBConnection): assert set(r["text"] for r in results) == {"lance database", "lance is cool"} results = ( - table.search("nce", query_type="fts").limit(10).to_list() - ) # spellchecker:disable-line + table.search( + "nce", # spellchecker:disable-line + query_type="fts", + ) + .limit(10) + .to_list() + ) assert len(results) == 0 results = table.search("la", query_type="fts").limit(10).to_list() diff --git a/python/python/tests/test_header_provider.py b/python/python/tests/test_header_provider.py index 84c5d7729..187b0a50a 100644 --- a/python/python/tests/test_header_provider.py +++ b/python/python/tests/test_header_provider.py @@ -54,7 +54,10 @@ class TestOAuthProvider: provider = OAuthProvider(fetcher) headers = provider.get_headers() - assert headers == {"Authorization": "Bearer token123"} + assert headers == { + "Authorization": "Bearer token123", + "x-lancedb-credential-type": "oidc", + } assert provider._current_token == "token123" assert provider._token_expires_at is not None @@ -73,14 +76,20 @@ class TestOAuthProvider: # First call headers1 = provider.get_headers() - assert headers1 == {"Authorization": "Bearer token1"} + assert headers1 == { + "Authorization": "Bearer token1", + "x-lancedb-credential-type": "oidc", + } # Wait for token to expire time.sleep(1.1) # Second call should refresh headers2 = provider.get_headers() - assert headers2 == {"Authorization": "Bearer token2"} + assert headers2 == { + "Authorization": "Bearer token2", + "x-lancedb-credential-type": "oidc", + } assert call_count == 2 def test_no_expiry_info(self): @@ -92,12 +101,18 @@ class TestOAuthProvider: provider = OAuthProvider(fetcher) headers = provider.get_headers() - assert headers == {"Authorization": "Bearer permanent_token"} + assert headers == { + "Authorization": "Bearer permanent_token", + "x-lancedb-credential-type": "oidc", + } assert provider._token_expires_at is None # Should not refresh on second call headers2 = provider.get_headers() - assert headers2 == {"Authorization": "Bearer permanent_token"} + assert headers2 == { + "Authorization": "Bearer permanent_token", + "x-lancedb-credential-type": "oidc", + } def test_missing_access_token(self): """Test error handling when access_token is missing.""" @@ -121,7 +136,10 @@ class TestOAuthProvider: provider = OAuthProvider(fetcher) headers = provider.get_headers() - assert headers == {"Authorization": "Bearer sync_token"} + assert headers == { + "Authorization": "Bearer sync_token", + "x-lancedb-credential-type": "oidc", + } class TestClientConfigIntegration: diff --git a/python/python/tests/test_hybrid_query.py b/python/python/tests/test_hybrid_query.py index 5e9b45ecb..61b8001cf 100644 --- a/python/python/tests/test_hybrid_query.py +++ b/python/python/tests/test_hybrid_query.py @@ -203,6 +203,93 @@ async def test_async_hybrid_query_default_limit(table: AsyncTable): assert texts.count("a") == 1 +@pytest.mark.asyncio +async def test_async_hybrid_query_offset(table: AsyncTable): + # The offset window of a hybrid query must be a suffix of the same query + # run without an offset. Skipping the first rows of each sub-query instead + # of the first rows of the fused result silently changes which rows land in + # the window. + full = await ( + table.query() + .nearest_to([0.0, 0.4]) + .nearest_to_text("dog") + .limit(4) + .with_row_id() + .to_arrow() + ) + assert len(full) == 4 + + second_page = await ( + table.query() + .nearest_to([0.0, 0.4]) + .nearest_to_text("dog") + .offset(2) + .limit(2) + .with_row_id() + .to_arrow() + ) + assert second_page["_rowid"].to_pylist() == full["_rowid"].to_pylist()[2:] + + first_page = await ( + table.query() + .nearest_to([0.0, 0.4]) + .nearest_to_text("dog") + .limit(2) + .with_row_id() + .to_arrow() + ) + # Paging through the result must visit every row exactly once: no row + # repeated from the previous page and none dropped between the two. + paged = first_page["_rowid"].to_pylist() + second_page["_rowid"].to_pylist() + assert sorted(paged) == sorted(full["_rowid"].to_pylist()) + + +@pytest.mark.asyncio +async def test_async_hybrid_query_fts_first_default_limit(table: AsyncTable): + # nearest_to() and nearest_to_text() build their new sibling sub-query from + # scratch, and that is the sub-query the default limit ends up on. So the + # side that carries the limit depends on the order the hybrid query was + # built in, and looking at only one side loses the limit for half the ways + # a hybrid query can be written. Without a limit the combined results are + # not truncated at all and the whole union of both candidate lists is + # returned. + await table.add([{"text": "dog", "vector": [50.0 + i, 50.0]} for i in range(10)]) + + result = await ( + table.query().nearest_to_text("dog").nearest_to([0.1, 0.1]).to_arrow() + ) + assert len(result) == 10 + + offset_result = await ( + table.query().nearest_to_text("dog").nearest_to([0.1, 0.1]).offset(2).to_arrow() + ) + assert len(offset_result) == 10 + + +@pytest.mark.asyncio +async def test_async_hybrid_query_explain_plan_matches_execution(table: AsyncTable): + # Paging rewrites the sub-queries: each one fetches limit + offset rows with + # no offset of its own, and the window is sliced out after fusion. The plans + # have to be built from those rewritten sub-queries, otherwise explain_plan + # and analyze_plan describe a query that is never run. + query = ( + table.query().nearest_to([0.0, 0.4]).nearest_to_text("dog").offset(2).limit(2) + ) + await query.to_arrow() + + plan = await query.explain_plan() + assert [ + line.strip() for line in plan.splitlines() if "GlobalLimitExec" in line + ] == [ + "GlobalLimitExec: skip=0, fetch=4", + "GlobalLimitExec: skip=0, fetch=4", + ] + + analyzed = await query.analyze_plan() + assert analyzed.count("skip=0, fetch=4") == 2 + assert "skip=2" not in analyzed + + def test_hybrid_query_offset(sync_table: Table): # The offset window of a hybrid query must be a suffix of the same query # run without an offset -- it must not be silently ignored. diff --git a/python/python/tests/test_materialized_views.py b/python/python/tests/test_materialized_views.py index 5fa3aa4fb..695ef142a 100644 --- a/python/python/tests/test_materialized_views.py +++ b/python/python/tests/test_materialized_views.py @@ -1,9 +1,15 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The LanceDB Authors +import contextlib +import http.server +import json +import threading + import lancedb import pytest from lancedb.materialized_view import MaterializedViewDefinition +from lancedb.remote.db import RemoteDBConnection STABLE_ROW_IDS = {"new_table_enable_stable_row_ids": "true"} @@ -22,6 +28,152 @@ def make_db(tmp_path): return db +@contextlib.contextmanager +def mock_remote_materialized_views(): + requests = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + requests.append(self.path) + encoded = json.dumps({"views": ["daily_sales"]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + with http.server.HTTPServer(("localhost", 0), Handler) as server: + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield f"http://localhost:{server.server_address[1]}", requests + finally: + server.shutdown() + thread.join() + + +@contextlib.contextmanager +def mock_remote_materialized_view_create(): + requests = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + requests.append((self.path, body)) + job_id = "mv-drop-123" if self.path.endswith("/drop") else "mv-create-123" + encoded = json.dumps({"job_id": job_id}).encode() + self.send_response(202) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + with http.server.HTTPServer(("localhost", 0), Handler) as server: + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield f"http://localhost:{server.server_address[1]}", requests + finally: + server.shutdown() + thread.join() + + +def test_remote_list_uses_namespace_route(): + with mock_remote_materialized_views() as (host, requests): + db = lancedb.connect( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + assert db.list_materialized_views() == ["daily_sales"] + assert requests == ["/v1/namespace/$/materialized_view/list"] + + +def test_remote_create_async_returns_server_job(): + with mock_remote_materialized_view_create() as (host, requests): + db = lancedb.connect( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + job = db.create_materialized_view_async("adults", "people", where="age >= 18") + assert job.id == "mv-create-123" + assert requests == [ + ( + "/v1/materialized_view/adults/create", + {"query": 'SELECT * FROM "people" WHERE age >= 18', "with_no_data": False}, + ) + ] + + +def test_remote_drop_async_returns_server_job(): + with mock_remote_materialized_view_create() as (host, requests): + db = lancedb.connect( + "db://dev", + api_key="fake", + host_override=host, + client_config={"retry_config": {"retries": 0}}, + ) + job = db.drop_materialized_view_async("adults") + assert job.id == "mv-drop-123" + assert requests == [("/v1/materialized_view/adults/drop", {})] + + +def test_sync_remote_create_uses_public_async_connection(): + calls = [] + + class StubAsyncTable: + name = "adults" + + class StubAsyncMaterializedView: + table = StubAsyncTable() + + class StrictAsyncConnection: + async def create_materialized_view( + self, + name, + source, + *, + select=None, + where=None, + limit=None, + with_no_data=False, + ): + calls.append((name, source, select, where, limit, with_no_data)) + return StubAsyncMaterializedView() + + async def drop_materialized_view(self, name, *, namespace_path=None): + calls.append(("drop", name, namespace_path)) + + db = RemoteDBConnection.__new__(RemoteDBConnection) + db._conn = StrictAsyncConnection() + db.db_name = "example" + db.serialize = lambda: "{}" + + view = db.create_materialized_view( + "adults", + "people", + select=["name"], + where="age >= 18", + limit=10, + with_no_data=True, + ) + assert view.name == "adults" + assert calls == [("adults", "people", ["name"], "age >= 18", 10, True)] + + db.drop_materialized_view("adults", namespace_path=["analytics"]) + assert calls[-1] == ("drop", "adults", ["analytics"]) + + def test_create_refresh_and_query(tmp_path): db = make_db(tmp_path) view = db.create_materialized_view( @@ -31,16 +183,34 @@ def test_create_refresh_and_query(tmp_path): where="age >= 18", ) assert view.name == "adults" - assert view.table.count_rows() == 0 - - result = view.refresh() - assert result.mode == "rebuild" - assert result.rows_written == 2 + assert view.table.count_rows() == 2 rows = view.table.search().to_list() assert sorted(row["shout"] for row in rows) == ["ADA", "GRACE"] +def test_create_and_refresh_jobs(tmp_path): + db = make_db(tmp_path) + create_job = db.create_materialized_view_async( + "adults", "people", where="age >= 18", with_no_data=True + ) + assert create_job.id is None + assert create_job.wait() is None + + view = db.open_materialized_view("adults") + refresh_job = view.refresh_async() + assert refresh_job.id is None + result = refresh_job.wait() + assert result.mode == "rebuild" + assert result.rows_written == 2 + assert view.table.count_rows() == 2 + + drop_job = db.drop_materialized_view_async("adults") + assert drop_job.id is None + assert drop_job.wait() is None + assert "adults" not in db.list_materialized_views() + + def test_definition_round_trips(tmp_path): db = make_db(tmp_path) db.create_materialized_view("adults", "people", where="age >= 18") @@ -56,7 +226,7 @@ def test_definition_round_trips(tmp_path): def test_incremental_refresh_after_append(tmp_path): db = make_db(tmp_path) - view = db.create_materialized_view("copy", "people") + view = db.create_materialized_view("copy", "people", with_no_data=True) view.refresh() db.open_table("people").add([{"name": "alan", "age": 41}]) @@ -70,7 +240,7 @@ def test_incremental_refresh_after_append(tmp_path): def test_incremental_refresh_after_update(tmp_path): db = make_db(tmp_path) - view = db.create_materialized_view("copy", "people") + view = db.create_materialized_view("copy", "people", with_no_data=True) view.refresh() db.open_table("people").update(where="name = 'kid'", values={"age": 8}) @@ -87,7 +257,7 @@ def test_legacy_storage_source_update_rebuilds(tmp_path): storage_options={**STABLE_ROW_IDS, "new_table_data_storage_version": "legacy"}, ) db.create_table("people", [{"name": "ada", "age": 36}, {"name": "kid", "age": 7}]) - view = db.create_materialized_view("copy", "people") + view = db.create_materialized_view("copy", "people", with_no_data=True) view.refresh() db.open_table("people").update(where="name = 'kid'", values={"age": 8}) @@ -104,6 +274,11 @@ def test_list_and_not_a_view(tmp_path): assert db.list_materialized_views() == ["adults"] with pytest.raises(ValueError, match="not a materialized view"): db.open_materialized_view("people") + with pytest.raises(ValueError, match="not a materialized view"): + db.drop_materialized_view("people") + + db.drop_materialized_view("adults") + assert db.list_materialized_views() == [] def test_invalid_expression_fails_at_create(tmp_path): @@ -119,7 +294,10 @@ async def test_async_create_refresh_and_open(tmp_path): await db.create_table("people", [{"name": "ada", "age": 36}]) view = await db.create_materialized_view( - "shouts", "people", select=[("shout", "upper(name)")] + "shouts", + "people", + select=[("shout", "upper(name)")], + with_no_data=True, ) result = await view.refresh() assert result.mode == "rebuild" @@ -131,11 +309,35 @@ async def test_async_create_refresh_and_open(tmp_path): assert await db.list_materialized_views() == ["shouts"] +@pytest.mark.asyncio +async def test_async_create_and_refresh_jobs(tmp_path): + db = await lancedb.connect_async(tmp_path, storage_options=STABLE_ROW_IDS) + await db.create_table("people", [{"name": "ada", "age": 36}]) + + create_job = await db.create_materialized_view_async( + "adults", "people", with_no_data=True + ) + assert create_job.id is None + assert await create_job.wait() is None + + view = await db.open_materialized_view("adults") + refresh_job = await view.refresh_async() + assert refresh_job.id is None + result = await refresh_job.wait() + assert result.mode == "rebuild" + assert result.rows_written == 1 + + drop_job = await db.drop_materialized_view_async("adults") + assert drop_job.id is None + assert await drop_job.wait() is None + assert "adults" not in await db.list_materialized_views() + + @pytest.mark.asyncio async def test_async_incremental(tmp_path): db = await lancedb.connect_async(tmp_path, storage_options=STABLE_ROW_IDS) await db.create_table("people", [{"name": "ada", "age": 36}]) - view = await db.create_materialized_view("copy", "people") + view = await db.create_materialized_view("copy", "people", with_no_data=True) await view.refresh() table = await db.open_table("people") @@ -157,7 +359,10 @@ def test_bare_select_names_are_quoted(tmp_path): db.create_table("odd_names", [{"order item": "widget", "select": 2}]) view = db.create_materialized_view( - "quoted", "odd_names", select=["order item", "select"] + "quoted", + "odd_names", + select=["order item", "select"], + with_no_data=True, ) result = view.refresh() assert result.rows_written == 1 @@ -166,19 +371,6 @@ def test_bare_select_names_are_quoted(tmp_path): assert rows[0]["select"] == 2 -@pytest.mark.asyncio -async def test_async_remote_is_refused_without_network(): - db = await lancedb.connect_async( - "db://nowhere", api_key="sk_test", region="us-east-1" - ) - with pytest.raises(NotImplementedError, match="local"): - await db.create_materialized_view("v", "src") - with pytest.raises(NotImplementedError, match="local"): - await db.open_materialized_view("v") - with pytest.raises(NotImplementedError, match="local"): - await db.list_materialized_views() - - def test_scalar_select_is_one_column(tmp_path): db = make_db(tmp_path) view = db.create_materialized_view("just_name", "people", select="name") @@ -235,6 +427,17 @@ def test_namespace_connection_materialized_views(tmp_path): with pytest.raises(ValueError, match="not a materialized view"): db.open_materialized_view("people") + create_job = db.create_materialized_view_async( + "job_view", "people", with_no_data=True + ) + assert create_job.wait() is None + refresh_job = db.open_materialized_view("job_view").refresh_async() + assert refresh_job.wait().rows_written == 2 + + assert db.drop_materialized_view_async("job_view").wait() is None + db.drop_materialized_view("adults") + assert db.list_materialized_views() == [] + @pytest.mark.asyncio async def test_async_namespace_connection_materialized_views(tmp_path): @@ -266,3 +469,51 @@ async def test_async_namespace_connection_materialized_views(tmp_path): handle._route_pushdown_to_rust == through_namespace._route_pushdown_to_rust ) assert handle._namespace_path == through_namespace._namespace_path + + create_job = await db.create_materialized_view_async( + "job_view", "people", with_no_data=True + ) + assert await create_job.wait() is None + job_view = await db.open_materialized_view("job_view") + refresh_job = await job_view.refresh_async() + assert (await refresh_job.wait()).rows_written == 2 + + drop_job = await db.drop_materialized_view_async("job_view") + assert await drop_job.wait() is None + await db.drop_materialized_view("adults") + assert await db.list_materialized_views() == [] + + +def test_namespaced_select_kind_is_read_and_unknown_kinds_are_refused(): + import json + + import pyarrow as pa + + from lancedb.materialized_view import _definition_from_schema + + def schema_with(definition: dict) -> pa.Schema: + return pa.schema([pa.field("id", pa.int32())]).with_metadata( + {b"mv.definition": json.dumps(definition).encode()} + ) + + # "namespaced_select" is the namespaced form of "select": same shape, + # a separate kind so readers that predate it refuse instead of + # resolving the source at the root. + definition = _definition_from_schema( + schema_with( + { + "kind": "namespaced_select", + "source_table": "people", + "source_namespace": ["ns"], + "projections": [{"output": "name", "expression": "name"}], + } + ), + "v", + ) + assert definition.source_table == "people" + assert definition.source_namespace == ["ns"] + + with pytest.raises(NotImplementedError, match="cannot refresh"): + _definition_from_schema( + schema_with({"kind": "select_v3", "source_table": "people"}), "v" + ) diff --git a/python/python/tests/test_namespace.py b/python/python/tests/test_namespace.py index f8cbfe92c..7f095b71d 100644 --- a/python/python/tests/test_namespace.py +++ b/python/python/tests/test_namespace.py @@ -193,7 +193,13 @@ class TestNamespaceConnection: ), ) - table = db.create_table("blob_table", data, namespace_path=["test_ns"]) + # Legacy v1 blob columns are only writable at file version <= 2.1. + table = db.create_table( + "blob_table", + data, + namespace_path=["test_ns"], + storage_options={"new_table_data_storage_version": "2.1"}, + ) df = table.to_pandas(blob_mode="lazy").sort_values("id") blob = df["blob"].iloc[0] diff --git a/python/python/tests/test_namespace_no_pylance.py b/python/python/tests/test_namespace_no_pylance.py new file mode 100644 index 000000000..7f5b086d6 --- /dev/null +++ b/python/python/tests/test_namespace_no_pylance.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +"""Namespace operations must not require the optional ``pylance`` dependency. + +The sync ``lancedb.connect()`` connection used to route namespace operations +through the Python ``lance_namespace`` client, whose ``dir`` implementation +lives in ``lance.namespace`` (shipped by the optional ``pylance`` extra). On an +install without that extra, even ``db.list_namespaces()`` failed, while the +async API worked because it goes straight to the native Rust connection. + +The ``without_pylance`` fixture below simulates a missing ``pylance`` so these +tests fail on any environment when the native routing regresses. The ground +truth remains the "Test without pylance or pandas" CI job, which runs this file +with ``pylance`` and ``pandas`` actually uninstalled -- so nothing here may +import either at module scope. +""" + +import sys +from importlib import import_module +from importlib.abc import MetaPathFinder + +import lancedb +import pyarrow as pa +import pytest + + +def _is_lance(module_name: str) -> bool: + # "lance_namespace" is a separate, non-optional package -- leave it alone. + return module_name == "lance" or module_name.startswith("lance.") + + +class _BlockLanceImports(MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if _is_lance(fullname): + raise ModuleNotFoundError(f"No module named {fullname!r}", name=fullname) + return None + + +@pytest.fixture +def without_pylance(monkeypatch): + """Make ``lance`` unimportable, as on an install without the pylance extra. + + Uninstalled means absent from ``sys.modules`` too, not merely unimportable: + LanceDB has code that branches on ``"lance" in sys.modules``, so a fixture + that poisons the entry instead of removing it would fail tests that a real + install passes. + """ + for name in list(sys.modules): + if _is_lance(name): + monkeypatch.delitem(sys.modules, name) + monkeypatch.setattr(sys, "meta_path", [_BlockLanceImports(), *sys.meta_path]) + + +def _schema() -> pa.Schema: + return pa.schema([pa.field("id", pa.int64())]) + + +def test_fixture_matches_an_uninstalled_pylance(without_pylance): + """Guard the guard: the other tests are meaningless if lance stays importable.""" + assert "lance" not in sys.modules + with pytest.raises(ModuleNotFoundError): + import_module("lance.namespace") + + +def test_list_namespaces_on_sync_connection(tmp_path, without_pylance): + """The original reproducer: this alone used to raise on a sync connection.""" + db = lancedb.connect(tmp_path) + assert db.list_namespaces().namespaces == [] + + +def test_sync_namespace_lifecycle(tmp_path, without_pylance): + db = lancedb.connect(tmp_path) + + db.create_namespace(["child"]) + assert db.list_namespaces().namespaces == ["child"] + assert db.list_namespaces(namespace_path=["child"]).namespaces == [] + db.describe_namespace(["child"]) + + db.create_namespace(["child", "grandchild"]) + assert db.list_namespaces(namespace_path=["child"]).namespaces == ["grandchild"] + + db.drop_namespace(["child", "grandchild"]) + db.drop_namespace(["child"]) + assert db.list_namespaces().namespaces == [] + + +def test_sync_namespaced_table_lifecycle(tmp_path, without_pylance): + db = lancedb.connect(tmp_path) + db.create_namespace(["child"]) + + table = db.create_table("tbl", schema=_schema(), namespace_path=["child"]) + assert table.namespace == ["child"] + table.add([{"id": 1}]) + + assert db.list_tables(namespace_path=["child"]).tables == ["tbl"] + assert db.list_tables().tables == [] + + opened = db.open_table("tbl", namespace_path=["child"]) + assert opened.namespace == ["child"] + assert opened.count_rows() == 1 + assert opened.search().limit(5).to_arrow().num_rows == 1 + + db.drop_table("tbl", namespace_path=["child"]) + assert db.list_tables(namespace_path=["child"]).tables == [] + db.drop_namespace(["child"]) + + +def test_sync_root_table_lifecycle(tmp_path, without_pylance): + """Root-namespace tables share the namespace plumbing, so cover them too.""" + db = lancedb.connect(tmp_path) + + table = db.create_table("tbl", schema=_schema()) + table.add([{"id": 1}]) + + assert db.table_names() == ["tbl"] + assert "tbl" in db + assert db["tbl"].count_rows() == 1 + + db.drop_table("tbl") + assert db.table_names() == [] + + +@pytest.mark.asyncio +async def test_async_namespace_lifecycle(tmp_path, without_pylance): + db = await lancedb.connect_async(tmp_path) + + await db.create_namespace(["child"]) + assert (await db.list_namespaces()).namespaces == ["child"] + + table = await db.create_table("tbl", schema=_schema(), namespace_path=["child"]) + await table.add([{"id": 1}]) + assert (await db.list_tables(namespace_path=["child"])).tables == ["tbl"] + assert await table.count_rows() == 1 + + await db.drop_table("tbl", namespace_path=["child"]) + await db.drop_namespace(["child"]) + assert (await db.list_namespaces()).namespaces == [] + + +def test_namespace_client_still_requires_pylance(tmp_path, without_pylance): + """``namespace_client()`` is the one namespace API that opts into pylance. + + It hands out a Python ``LanceNamespace``, so it cannot be served natively. + Pinned here so the boundary stays explicit and the error stays actionable. + """ + db = lancedb.connect(tmp_path) + with pytest.raises(ValueError, match="lance.namespace.DirectoryNamespace"): + db.namespace_client() diff --git a/python/python/tests/test_package_metadata.py b/python/python/tests/test_package_metadata.py index 5792f457b..27def814f 100644 --- a/python/python/tests/test_package_metadata.py +++ b/python/python/tests/test_package_metadata.py @@ -9,6 +9,15 @@ from pathlib import Path import pytest +@pytest.mark.parametrize("name", ["JobInfo", "JobDescription", "JobFailureInfo"]) +def test_job_metadata_types_have_resolvable_modules(name): + """Documentation tools resolve re-exports through each type's module.""" + public_type = getattr(importlib.import_module("lancedb.job"), name) + defining_module = importlib.import_module(public_type.__module__) + + assert getattr(defining_module, public_type.__name__, None) is public_type + + def test_pyo3_abi_matches_minimum_supported_python(): project_dir = Path(__file__).parents[2] pyproject = (project_dir / "pyproject.toml").read_text() diff --git a/python/python/tests/test_query.py b/python/python/tests/test_query.py index ff62b2b51..e3d55917d 100644 --- a/python/python/tests/test_query.py +++ b/python/python/tests/test_query.py @@ -40,6 +40,10 @@ from utils import exception_output from importlib.util import find_spec +# Legacy v1 blob columns are only writable at file version <= 2.1. +LEGACY_BLOB_STORAGE_OPTIONS = {"new_table_data_storage_version": "2.1"} + + def _blob_query_data(): return pa.table( { @@ -119,13 +123,17 @@ def _assert_blob_bytes_projection(df): def _blob_query_table(db, name, blob_schema): if blob_schema == "v1": - return db.create_table(name, _blob_query_data()) + return db.create_table( + name, _blob_query_data(), storage_options=LEGACY_BLOB_STORAGE_OPTIONS + ) return _create_blob_v2_query_table(db, name) async def _blob_query_table_async(db, name, blob_schema): if blob_schema == "v1": - return await db.create_table(name, _blob_query_data()) + return await db.create_table( + name, _blob_query_data(), storage_options=LEGACY_BLOB_STORAGE_OPTIONS + ) return await _create_blob_v2_query_table_async(db, name) @@ -275,7 +283,9 @@ async def test_query_to_pandas_kwargs(table, table_async): def test_plain_scan_query_to_pandas_blob_modes(tmp_db, blob_mode): pytest.importorskip("lance") table = tmp_db.create_table( - f"test_query_to_pandas_blob_{blob_mode}", _blob_query_data() + f"test_query_to_pandas_blob_{blob_mode}", + _blob_query_data(), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, ) df = ( @@ -322,7 +332,9 @@ def test_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow( ): pytest.importorskip("lance") table = tmp_db.create_table( - "test_query_to_pandas_blob_no_arrow_collect", _blob_query_data() + "test_query_to_pandas_blob_no_arrow_collect", + _blob_query_data(), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, ) query = table.search().where("id = 1").select(["id", "blob"]) @@ -347,7 +359,9 @@ def test_plain_scan_query_to_pandas_blob_descriptions_flatten_uses_scanner( ): pytest.importorskip("lance") table = tmp_db.create_table( - "test_query_to_pandas_blob_desc_flatten", _blob_query_data() + "test_query_to_pandas_blob_desc_flatten", + _blob_query_data(), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, ) query = table.search().where("id = 1").select(["id", "blob"]) @@ -365,7 +379,11 @@ def test_plain_scan_query_to_pandas_blob_descriptions_flatten_uses_scanner( def test_plain_scan_query_to_pandas_scanner_state(tmp_db): pytest.importorskip("lance") data = _blob_query_data() - table = tmp_db.create_table("test_query_to_pandas_scanner_state", data.slice(0, 2)) + table = tmp_db.create_table( + "test_query_to_pandas_scanner_state", + data.slice(0, 2), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, + ) table.add(data.slice(2, 2)) fragments = table.to_lance().get_fragments() @@ -400,7 +418,9 @@ def test_plain_scan_query_to_pandas_scanner_state(tmp_db): async def test_async_plain_scan_query_to_pandas_blob_projection(tmp_db_async): pytest.importorskip("lance") table = await tmp_db_async.create_table( - "test_async_query_to_pandas_blob_projection", _blob_query_data() + "test_async_query_to_pandas_blob_projection", + _blob_query_data(), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, ) lazy_df = await ( @@ -452,7 +472,9 @@ async def test_async_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow ): pytest.importorskip("lance") table = await tmp_db_async.create_table( - "test_async_query_to_pandas_blob_no_arrow_collect", _blob_query_data() + "test_async_query_to_pandas_blob_no_arrow_collect", + _blob_query_data(), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, ) query = table.query().where("id = 1").select(["id", "blob"]) @@ -474,7 +496,11 @@ async def test_async_plain_scan_query_to_pandas_blob_mode_does_not_collect_arrow def test_vector_query_to_pandas_blob_mode_requires_native_path(tmp_db): pytest.importorskip("lance") - table = tmp_db.create_table("test_vector_query_blob_mode", _blob_query_data()) + table = tmp_db.create_table( + "test_vector_query_blob_mode", + _blob_query_data(), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, + ) with pytest.raises(RuntimeError, match="Lance native pandas conversion"): table.search([1.0, 0.0]).select(["blob", "vector"]).limit(1).to_pandas( @@ -485,7 +511,9 @@ def test_vector_query_to_pandas_blob_mode_requires_native_path(tmp_db): def test_vector_query_to_pandas_blob_descriptions_requires_plain_scan(tmp_db): pytest.importorskip("lance") table = tmp_db.create_table( - "test_vector_query_blob_descriptions", _blob_query_data() + "test_vector_query_blob_descriptions", + _blob_query_data(), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, ) with pytest.raises(RuntimeError, match="plain scan query"): @@ -1923,6 +1951,21 @@ def test_take_queries(tmp_path): 17, ] + # Duplicate offsets are occurrences, not set members. Ordering is unspecified. + assert sorted(table.take_offsets([5, 2, 5, 17]).to_pandas()["idx"].to_list()) == [ + 2, + 5, + 5, + 17, + ] + + # Converting a take builder to its serializable query representation must + # retain occurrence metadata and execute with the same multiplicity. + query = table.take_offsets([5, 2, 5, 17]).select(["idx"]).to_query_object() + assert query.take_offsets == [5, 2, 5, 17] + converted = table._execute_query(query).read_all() + assert sorted(converted["idx"].to_pylist()) == [2, 5, 5, 17] + # Take by row id assert list( sorted(table.take_row_ids([5, 2, 17]).to_pandas()["idx"].to_list()) diff --git a/python/python/tests/test_remote_db.py b/python/python/tests/test_remote_db.py index ab0df386d..1e5a71e9a 100644 --- a/python/python/tests/test_remote_db.py +++ b/python/python/tests/test_remote_db.py @@ -479,24 +479,49 @@ def test_remote_permutation_is_picklable(): match = re.search( r"_rowoffset\s+in\s+\((.*?)\)", body["filter"], re.IGNORECASE ) - offsets = [int(o.strip()) for o in match.group(1).split(",")] + offsets = list( + dict.fromkeys(int(o.strip()) for o in match.group(1).split(",")) + ) else: offsets = list(range(len(rows))) - table = pa.table({"a": [rows[offset] for offset in offsets]}) + columns = body.get("columns") or ["a"] + table = pa.table( + { + column: ( + [rows[offset] for offset in offsets] + if column == "a" + else offsets + ) + for column in columns + } + ) request.send_response(200) request.send_header("Content-Type", "application/vnd.apache.arrow.file") request.end_headers() with pa.ipc.new_file(request.wfile, schema=table.schema) as writer: - writer.write_table(table) + writer.write_table(table, max_chunksize=2) else: request.send_response(404) request.end_headers() with mock_lancedb_connection(handler) as db: - permutation = Permutation.identity(db.open_table("test")) + table = db.open_table("test") + assert table.take_offsets([0, 2, 0, 4]).to_list() == [ + {"a": 0}, + {"a": 0}, + {"a": 2}, + {"a": 4}, + ] + + permutation = Permutation.identity(table) restored = pickle.loads(pickle.dumps(permutation)) - assert restored.__getitems__([0, 2, 4]) == [{"a": 0}, {"a": 2}, {"a": 4}] + assert restored.__getitems__([0, 2, 0, 4]) == [ + {"a": 0}, + {"a": 2}, + {"a": 0}, + {"a": 4}, + ] def test_create_table_exist_ok(): @@ -795,11 +820,13 @@ def test_table_create_indices(): scalar_req = received_requests[0] assert "name" in scalar_req assert scalar_req["name"] == "custom_scalar_idx" + assert scalar_req["replace"] is False # Check FTS index request has custom name fts_req = received_requests[1] assert "name" in fts_req assert fts_req["name"] == "custom_fts_idx" + assert fts_req["replace"] is False assert fts_req["block_size"] == 256 assert fts_req["custom_stop_words"] == ["cloud"] @@ -807,6 +834,7 @@ def test_table_create_indices(): vector_req = received_requests[2] assert "name" in vector_req assert vector_req["name"] == "custom_vector_idx" + assert "replace" not in vector_req table.wait_for_index(["custom_scalar_idx"], timedelta(seconds=2)) table.wait_for_index( @@ -1079,6 +1107,9 @@ def test_remote_create_index_new_api(): table.create_index("text", config=FTS(block_size=256)) # IvfRq via new API table.create_index("vector", config=IvfRq(distance_type="l2")) + table.create_index( + "vector", config=IvfPq(distance_type="l2"), replace=False + ) # Legacy index_type="IVF_RQ" routes to IvfRq config under the hood. with pytest.warns(DeprecationWarning, match="create_index"): @@ -1088,15 +1119,17 @@ def test_remote_create_index_new_api(): num_partitions=8, ) - assert len(received_requests) == 5 + assert len(received_requests) == 6 assert [req["column"] for req in received_requests] == [ "vector", "category", "text", "vector", "vector", + "vector", ] assert received_requests[2]["block_size"] == 256 + assert received_requests[4]["replace"] is False def test_table_wait_for_index_timeout(): @@ -2434,7 +2467,7 @@ def test_remote_blob_byte_apis_not_supported_on_old_server(): def test_remote_connection_jobs_surface(): - from lancedb.exceptions import JobFailedError + from lancedb.exceptions import JobFailedError, JobNotFoundError schema = pa.schema([("state", pa.string())]) batch = pa.record_batch([pa.array(["created", "done"])], schema=schema) @@ -2442,6 +2475,7 @@ def test_remote_connection_jobs_surface(): with pa.ipc.new_stream(sink, schema) as writer: writer.write_batch(batch) events_body = sink.getvalue().to_pybytes() + query_events_payloads = [] def handler(request): content_len = int(request.headers.get("Content-Length", 0)) @@ -2479,6 +2513,22 @@ def test_remote_connection_jobs_surface(): request.end_headers() request.wfile.write(json.dumps(rsp).encode()) elif request.path == "/v1/jobs/describe": + if payload["job_id"] == "job-2": + request.send_response(200) + request.send_header("Content-Type", "application/json") + request.end_headers() + request.wfile.write( + json.dumps( + dict( + job_id="job-2", + job_type="refresh_column", + job_state="DONE", + creation_ms=2000, + result=dict(rows_assigned=1000000, rows_failed=0), + ) + ).encode() + ) + return if payload["job_id"] != "job-1": request.send_response(404) request.end_headers() @@ -2510,7 +2560,7 @@ def test_remote_connection_jobs_surface(): request.end_headers() request.wfile.write(b'{"job_id": "job-1"}') elif request.path == "/v1/jobs/query_events": - assert payload["job_id"] == "job-1" + query_events_payloads.append(payload) request.send_response(200) request.send_header("Content-Type", "application/vnd.apache.arrow.stream") request.end_headers() @@ -2526,24 +2576,109 @@ def test_remote_connection_jobs_surface(): assert jobs[0].table == "t1" assert jobs[1].state == "finished" - description = db.get_job("job-1") - assert description.job_type == "create_index" - assert description.state == "failed" - assert json.loads(description.spec_json) == {"column": "vec"} - assert description.failure.message == "worker died" - assert description.failure.retryable is True - assert db.get_job("missing") is None - assert db.cancel_job("job-1") is True assert db.cancel_job("missing") is False - batches = db.job_history("job-1") - assert len(batches) == 1 - assert batches[0].num_rows == 2 - assert batches[0].column("state").to_pylist() == ["created", "done"] + # Opening a job hands back a populated handle; a missing one fails. + with pytest.raises(JobNotFoundError, match="missing"): + db.open_job("missing") + finished = db.open_job("job-2") + assert finished.state == "finished" + assert finished.result == {"rows_assigned": 1000000, "rows_failed": 0} - job = db.job("job-1") + job = db.open_job("job-1") assert job.id == "job-1" + # Opening already populated the handle. + assert job.state == "failed" + assert job.spec == {"column": "vec"} + assert job.failure.message == "worker died" assert job.status() == "failed" with pytest.raises(JobFailedError, match="worker died"): job.wait(timeout=timedelta(seconds=5)) + + +def test_remote_job_handle_reports_its_own_detail(): + schema = pa.schema([("state", pa.string())]) + batch = pa.record_batch([pa.array(["claim_complete"])], schema=schema) + sink = pa.BufferOutputStream() + with pa.ipc.new_stream(sink, schema) as writer: + writer.write_batch(batch) + events_body = sink.getvalue().to_pybytes() + event_payloads = [] + + def handler(request): + content_len = int(request.headers.get("Content-Length", 0)) + body = request.rfile.read(content_len) if content_len > 0 else b"" + payload = json.loads(body) if body else {} + if request.path == "/v1/jobs/describe": + request.send_response(200) + request.send_header("Content-Type", "application/json") + request.end_headers() + request.wfile.write( + json.dumps( + dict( + job_id="job-1", + job_type="refresh_column", + job_state="DONE", + creation_ms=2000, + spec=dict(column="vec"), + result=dict(rows_assigned=1000000), + ) + ).encode() + ) + elif request.path == "/v1/jobs/query_events": + event_payloads.append(payload) + request.send_response(200) + request.send_header("Content-Type", "application/vnd.apache.arrow.stream") + request.end_headers() + request.wfile.write(events_body) + else: + request.send_response(404) + request.end_headers() + + with mock_lancedb_connection(handler) as db: + job = db.open_job("job-1") + + # Opening populates the handle in the same round trip. + assert job.state == "finished" + job.refresh() + assert job.job_type == "refresh_column" + assert job.creation_ms == 2000 + assert job.spec == {"column": "vec"} + assert job.result == {"rows_assigned": 1000000} + assert job.failure is None + # The JSON payloads stay reachable, but as internal APIs. + assert json.loads(job._spec_json) == {"column": "vec"} + assert json.loads(job._result_json) == {"rows_assigned": 1000000} + + # print() shows everything the handle knows and nothing it does not. + # print() lays every known field out on its own line, with the JSON + # payloads indented rather than crammed onto one line. + assert repr(job) == "\n".join( + [ + "Job(", + " id='job-1',", + " state='finished',", + " job_type='refresh_column',", + " creation_ms=2000,", + " spec={", + ' "column": "vec"', + " },", + " result={", + ' "rows_assigned": 1000000', + " },", + ")", + ] + ) + # Nothing it does not know shows up. + assert "failure" not in repr(job) + + events = job.events(filter="state = 'claim_complete'", limit=500) + assert isinstance(events, pa.Table) + assert events.column("state").to_pylist() == ["claim_complete"] + # The handle supplies job_id; the caller only narrows the query. + assert event_payloads[-1] == { + "job_id": "job-1", + "limit": 500, + "filter": "state = 'claim_complete'", + } diff --git a/python/python/tests/test_rerankers.py b/python/python/tests/test_rerankers.py index 372a6b0f7..4430fa98f 100644 --- a/python/python/tests/test_rerankers.py +++ b/python/python/tests/test_rerankers.py @@ -81,7 +81,7 @@ def get_test_table(tmp_path): "but his son was mortal", "there hasn't been a good battlefield game since 2142", "I wish they would make another one", - "campains are not as good as they used to be", + "campaigns are not as good as they used to be", "Multiplayer and open world games have destroyed the single player experience", "Maybe the future is console games", "I don't know", diff --git a/python/python/tests/test_sql.py b/python/python/tests/test_sql.py new file mode 100644 index 000000000..eeadb3a33 --- /dev/null +++ b/python/python/tests/test_sql.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +from uuid import UUID + +import pytest +import pyarrow as pa + +import lancedb +from lancedb import _lancedb +from lancedb.arrow import AsyncRecordBatchReader +from lancedb.db import AsyncConnection +from lancedb.remote.db import RemoteDBConnection +from lancedb.sql import AsyncQuery, Query + +NIL_QUERY_ID = UUID(int=0) + + +class FakeNativeQuery: + id = UUID("0198f1b2-c3d4-7e5f-8123-456789abcdef") + + async def reader(self): + return pa.table({"value": [1, 2]}) + + +class FakeNativeConnection: + async def execute_query_async(self, query, *, default_namespace_path=None): + return FakeNativeQuery() + + +class FakeAsyncConnection: + async def execute_query_async(self, query, *, default_namespace_path=None): + return AsyncQuery(FakeNativeQuery()) + + +def remote_connection(sql_host_override=None): + return lancedb.connect( + "db://analytics", + api_key="test-key", + host_override="http://localhost:10024", + sql_host_override=sql_host_override, + ) + + +def test_sql_is_connection_scoped(): + assert hasattr(lancedb, "sql") + assert not callable(lancedb.sql) + assert not hasattr(_lancedb, "sql") + assert not hasattr(remote_connection(), "sql") + assert hasattr(remote_connection(), "execute_query") + assert hasattr(remote_connection(), "execute_query_async") + assert hasattr(remote_connection(), "describe_query") + + +def test_query_id_is_uuid(): + query = AsyncQuery(FakeNativeQuery()) + assert isinstance(query.id, UUID) + assert Query(query).id == query.id + + +def test_connection_serializes_sql_host_override(): + endpoint = "grpc+tls://sql.example.com:10026" + restored = lancedb.deserialize_conn( + remote_connection(sql_host_override=endpoint).serialize() + ) + assert restored.sql_host_override == endpoint + + +@pytest.mark.asyncio +async def test_async_sql_reader_is_record_batch_stream(): + reader = await AsyncQuery(FakeNativeQuery()).reader() + assert isinstance(reader, AsyncRecordBatchReader) + assert (await reader.read_all())[0].column(0).to_pylist() == [1, 2] + + +def test_sync_sql_reader_is_record_batch_reader(): + reader = Query(AsyncQuery(FakeNativeQuery())).reader() + assert isinstance(reader, pa.RecordBatchReader) + assert reader.read_all().column(0).to_pylist() == [1, 2] + + +def test_execute_query_returns_blocking_reader(): + connection = RemoteDBConnection.__new__(RemoteDBConnection) + connection._conn = FakeAsyncConnection() + reader = connection.execute_query("SELECT 1") + assert isinstance(reader, pa.RecordBatchReader) + assert reader.read_all().column(0).to_pylist() == [1, 2] + + +@pytest.mark.asyncio +async def test_async_execute_query_returns_async_reader(): + connection = AsyncConnection(FakeNativeConnection()) + reader = await connection.execute_query("SELECT 1") + assert isinstance(reader, AsyncRecordBatchReader) + assert (await reader.read_all())[0].column(0).to_pylist() == [1, 2] + + +def test_local_connection_rejects_sql(tmp_path): + connection = lancedb.connect(tmp_path) + with pytest.raises(NotImplementedError, match="SQL"): + connection.execute_query("SELECT 1") + with pytest.raises(NotImplementedError, match="SQL"): + connection.execute_query_async("SELECT 1") + with pytest.raises(NotImplementedError, match="SQL"): + connection.describe_query(NIL_QUERY_ID) + + +@pytest.mark.asyncio +async def test_local_async_connection_rejects_sql(tmp_path): + connection = await lancedb.connect_async(tmp_path) + with pytest.raises(NotImplementedError, match="SQL"): + await connection.execute_query("SELECT 1") + with pytest.raises(NotImplementedError, match="SQL"): + await connection.execute_query_async("SELECT 1") + with pytest.raises(NotImplementedError, match="SQL"): + await connection.describe_query(NIL_QUERY_ID) + + +@pytest.mark.asyncio +async def test_async_namespace_connection_rejects_sql(tmp_path): + connection = lancedb.connect_namespace_async("dir", {"root": str(tmp_path)}) + with pytest.raises(NotImplementedError, match="SQL"): + await connection.execute_query("SELECT 1") + with pytest.raises(NotImplementedError, match="SQL"): + await connection.execute_query_async("SELECT 1") + with pytest.raises(NotImplementedError, match="SQL"): + await connection.describe_query(NIL_QUERY_ID) + + +def test_describe_query_requires_uuid(): + with pytest.raises(TypeError, match="UUID"): + remote_connection().describe_query(str(NIL_QUERY_ID)) + + +@pytest.mark.parametrize( + "default_namespace_path", + ["public", ("public",), [1]], +) +def test_execute_query_async_requires_namespace_path_list(default_namespace_path): + with pytest.raises(ValueError, match="default_namespace_path"): + remote_connection().execute_query_async( + "SELECT 1", default_namespace_path=default_namespace_path + ) + + +def test_execute_query_async_rejects_invalid_endpoint(): + connection = remote_connection(sql_host_override="invalid://localhost") + with pytest.raises(ValueError, match="sql_host_override"): + connection.execute_query_async("SELECT 1") + + +@pytest.mark.parametrize( + "default_namespace_path", + [[""], ["café"], ["pub\tlic"], ["events$raw"]], +) +def test_execute_query_async_rejects_invalid_namespace_components( + default_namespace_path, +): + with pytest.raises(ValueError, match="default_namespace_path"): + remote_connection().execute_query_async( + "SELECT 1", default_namespace_path=default_namespace_path + ) diff --git a/python/python/tests/test_table.py b/python/python/tests/test_table.py index 649264def..cc468236b 100644 --- a/python/python/tests/test_table.py +++ b/python/python/tests/test_table.py @@ -64,15 +64,23 @@ async def _blob_v2_table_async(db: AsyncConnection, name: str): return table +# Legacy v1 blob columns are only writable at file version <= 2.1. +LEGACY_BLOB_STORAGE_OPTIONS = {"new_table_data_storage_version": "2.1"} + + def _blob_table(db: DBConnection, name: str, blob_schema: str): if blob_schema == "v1": - return db.create_table(name, data=_blob_test_data()) + return db.create_table( + name, data=_blob_test_data(), storage_options=LEGACY_BLOB_STORAGE_OPTIONS + ) return _blob_v2_table(db, name) async def _blob_table_async(db: AsyncConnection, name: str, blob_schema: str): if blob_schema == "v1": - return await db.create_table(name, data=_blob_test_data()) + return await db.create_table( + name, data=_blob_test_data(), storage_options=LEGACY_BLOB_STORAGE_OPTIONS + ) return await _blob_v2_table_async(db, name) @@ -147,7 +155,11 @@ def test_table_to_pandas_invalid_blob_mode_non_blob_table(tmp_db: DBConnection): @pytest.mark.parametrize("blob_mode", ["lazy", "bytes", "descriptions"]) def test_table_to_pandas_blob_modes(tmp_db: DBConnection, blob_mode): pytest.importorskip("lance") - table = tmp_db.create_table(f"test_to_pandas_blob_{blob_mode}", _blob_test_data()) + table = tmp_db.create_table( + f"test_to_pandas_blob_{blob_mode}", + _blob_test_data(), + storage_options=LEGACY_BLOB_STORAGE_OPTIONS, + ) df = table.to_pandas(blob_mode=blob_mode) @@ -2682,6 +2694,43 @@ def test_merge_insert(mem_db: DBConnection): ) +def test_merge_insert_composite_key(mem_db: DBConnection): + table = mem_db.create_table( + "my_table", + data=pa.table( + { + "shard": ["a", "a", "b"], + "id": [1, 2, 1], + "val": ["x", "y", "z"], + } + ), + ) + + # ("a", 1) matches an existing row and updates it. ("b", 2) agrees with an + # existing row on each key column separately but on neither pair, so it is + # an insert. + new_data = pa.table({"shard": ["a", "b"], "id": [1, 2], "val": ["X", "W"]}) + res = ( + table.merge_insert(["shard", "id"]) + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(new_data) + ) + assert res.num_updated_rows == 1 + assert res.num_inserted_rows == 1 + + expected = pa.table( + { + "shard": ["a", "a", "b", "b"], + "id": [1, 2, 1, 2], + "val": ["X", "y", "z", "W"], + } + ) + assert table.to_arrow().sort_by([("shard", "ascending"), ("id", "ascending")]) == ( + expected + ) + + def test_merge_insert_nullable_pandas_into_pydantic_schema(mem_db: DBConnection): # Regression test for https://github.com/lancedb/lancedb/issues/2366 pd = pytest.importorskip("pandas") @@ -2930,28 +2979,29 @@ async def test_merge_insert_async(mem_db_async: AsyncConnection): assert (await table.to_arrow()).sort_by("a") == expected +def _json_arrow_table(schema, rows): + json_type = schema.field("j").type + json_values = pa.ExtensionArray.from_storage( + json_type, + pa.array([value for _, value in rows], type=json_type.storage_type), + ) + return pa.Table.from_arrays( + [pa.array([row_id for row_id, _ in rows]), json_values], schema=schema + ) + + @pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") @pytest.mark.asyncio async def test_merge_insert_encodes_json(mem_db_async: AsyncConnection): - json_type = pa.json_() - schema = pa.schema([pa.field("id", pa.string()), pa.field("j", json_type)]) - - def json_table(rows): - json_values = pa.ExtensionArray.from_storage( - json_type, - pa.array([value for _, value in rows], type=json_type.storage_type), - ) - return pa.Table.from_arrays( - [pa.array([row_id for row_id, _ in rows]), json_values], schema=schema - ) + schema = pa.schema([pa.field("id", pa.string()), pa.field("j", pa.json_())]) table = await mem_db_async.create_table("json_merge", schema=schema) - await table.add(json_table([("a", '{"k": 1}'), ("b", '{"k": 9}')])) + await table.add(_json_arrow_table(schema, [("a", '{"k": 1}'), ("b", '{"k": 9}')])) await ( table.merge_insert("id") .when_matched_update_all() - .execute(json_table([("a", '{"k": 2}')])) + .execute(_json_arrow_table(schema, [("a", '{"k": 2}')])) ) rows = sorted(await table.query().to_list(), key=lambda row: row["id"]) @@ -2966,20 +3016,176 @@ async def test_merge_insert_encodes_json(mem_db_async: AsyncConnection): @pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") @pytest.mark.asyncio async def test_add_sanitization_encodes_json(mem_db_async: AsyncConnection): - json_type = pa.json_() - schema = pa.schema([pa.field("id", pa.string()), pa.field("j", json_type)]) - json_values = pa.ExtensionArray.from_storage( - json_type, pa.array(['{"k": 3}'], type=json_type.storage_type) - ) - data = pa.Table.from_arrays([pa.array(["c"]), json_values], schema=schema) + schema = pa.schema([pa.field("id", pa.string()), pa.field("j", pa.json_())]) table = await mem_db_async.create_table("json_add", schema=schema) - await table.add(data, on_bad_vectors="fill") + await table.add( + _json_arrow_table(schema, [("c", '{"k": 3}')]), on_bad_vectors="fill" + ) rows = await table.query().where("json_extract(j, '$.k') = '3'").to_list() assert rows == [{"id": "c", "j": '{"k":3}'}] +@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") +@pytest.mark.asyncio +async def test_add_all_null_json_batch(mem_db_async: AsyncConnection): + """A batch of dicts whose json values are all None infers as pa.null(), which used + to fail with a `json` vs `large_binary` schema mismatch. A row-at-a-time insert of + an optional json column always looks like this.""" + schema = pa.schema([pa.field("id", pa.string()), pa.field("j", pa.json_())]) + table = await mem_db_async.create_table("json_nulls", schema=schema) + + await table.add([{"id": "a", "j": None}]) + assert await table.count_rows() == 1 + + # ... and again once real JSON has been written. + await table.add(_json_arrow_table(schema, [("b", '{"k": 9}')])) + await table.add([{"id": "c", "j": None}]) + + rows = sorted(await table.query().to_list(), key=lambda row: row["id"]) + assert rows == [ + {"id": "a", "j": None}, + {"id": "b", "j": '{"k":9}'}, + {"id": "c", "j": None}, + ] + + # The nulls must not disturb reads of the column. + filtered = await table.query().where("json_extract(j, '$.k') = '9'").to_list() + assert filtered == [{"id": "b", "j": '{"k":9}'}] + assert len(await table.query().where("j IS NULL").to_list()) == 2 + + +@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") +def test_add_all_null_json_batch_sync(mem_db: DBConnection): + schema = pa.schema([pa.field("id", pa.string()), pa.field("j", pa.json_())]) + table = mem_db.create_table("json_nulls_sync", schema=schema) + + table.add([{"id": "a", "j": None}]) + + assert table.count_rows() == 1 + assert table.to_arrow()["j"].to_pylist() == [None] + + +@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("values", "expected"), + [ + ([None], [None]), + ([None, '{"k": 1}'], [None, '{"k":1}']), + (['{"k": 2}'], ['{"k":2}']), + ], +) +async def test_add_list_of_dicts_to_json_column( + mem_db_async: AsyncConnection, values, expected +): + schema = pa.schema([pa.field("id", pa.int64()), pa.field("value", pa.json_())]) + table = await mem_db_async.create_table("json_list_add", schema=schema) + + await table.add([{"id": idx, "value": value} for idx, value in enumerate(values)]) + + rows = (await table.to_arrow()).sort_by("id").to_pylist() + assert [row["value"] for row in rows] == expected + + +@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") +@pytest.mark.asyncio +async def test_add_list_of_dicts_to_nested_json_column( + mem_db_async: AsyncConnection, +): + json_field = pa.field("value", pa.json_()) + info_field = pa.field("info", pa.struct([json_field])) + info = pa.StructArray.from_arrays( + [pa.array(['{"seed": 0}'], type=pa.json_())], fields=[json_field] + ) + seed = pa.Table.from_arrays( + [pa.array([0], type=pa.int64()), info], + schema=pa.schema([pa.field("id", pa.int64()), info_field]), + ) + table = await mem_db_async.create_table("nested_json_list_add", data=seed) + + await table.add([{"id": 1, "info": {"value": '{"k": 1}'}}]) + await table.add([{"id": 2, "info": {"value": '{"k": 2}'}}], on_bad_vectors="fill") + + rows = (await table.to_arrow()).sort_by("id").to_pylist() + assert rows == [ + {"id": 0, "info": {"value": '{"seed":0}'}}, + {"id": 1, "info": {"value": '{"k":1}'}}, + {"id": 2, "info": {"value": '{"k":2}'}}, + ] + + +@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") +@pytest.mark.asyncio +async def test_add_list_of_dicts_to_json_list_column(mem_db_async: AsyncConnection): + """JSON inside a list must be JSONB-encoded, not stored as the raw text. + + Storing raw text appends without error but leaves the column unreadable, so the + round trip is checked with a filter as well as by value. + """ + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("docs", pa.list_(pa.field("item", pa.json_()))), + ] + ) + table = await mem_db_async.create_table("json_list_add", schema=schema) + + await table.add([{"id": 1, "docs": ['{"k": 1}', '{"k": 2}']}]) + await table.add([{"id": 2, "docs": ['{"k": 3}']}], on_bad_vectors="fill") + + rows = (await table.to_arrow()).sort_by("id").to_pylist() + assert rows == [ + {"id": 1, "docs": ['{"k":1}', '{"k":2}']}, + {"id": 2, "docs": ['{"k":3}']}, + ] + + matched = await table.query().where("json_extract(docs[1], '$.k') = 3").to_arrow() + assert matched.column("id").to_pylist() == [2] + + +@pytest.mark.skipif(not hasattr(pa, "json_"), reason="requires PyArrow JSON type") +@pytest.mark.asyncio +async def test_add_map_of_json_values(mem_db_async: AsyncConnection): + """JSON in a map's values needs the same encoding a list's items do.""" + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("m", pa.map_(pa.string(), pa.json_())), + ] + ) + table = await mem_db_async.create_table( + "json_map_add", + schema=schema, + storage_options={"new_table_data_storage_version": "2.2"}, + ) + + def batch(row_id: int, text: str) -> pa.Table: + return pa.table( + { + "id": pa.array([row_id], type=pa.int64()), + "m": pa.array([[("k", text)]], type=pa.map_(pa.string(), pa.string())), + } + ) + + await table.add(batch(1, '{"x": 1}')) + await table.add(batch(2, '{"x": 2}'), on_bad_vectors="fill") + + rows = (await table.to_arrow()).sort_by("id").to_pylist() + assert rows == [ + {"id": 1, "m": [("k", '{"x":1}')]}, + {"id": 2, "m": [("k", '{"x":2}')]}, + ] + + matched = ( + await table.query() + .where("json_extract(element_at(m, 'k')[1], '$.x') = '2'") + .to_arrow() + ) + assert matched.column("id").to_pylist() == [2] + + def test_create_with_embedding_function(mem_db: DBConnection): class MyTable(LanceModel): text: str @@ -3305,7 +3511,7 @@ def test_empty_query(mem_db: DBConnection): # None is the same as default df = table.search().select(["id"]).limit(None).to_arrow() assert df.num_rows == 100 - # invalid limist is the same as None, wihch is the same as default + # invalid limist is the same as None, which is the same as default df = table.search().select(["id"]).limit(-1).to_arrow() assert df.num_rows == 100 # valid limit should work @@ -4016,7 +4222,7 @@ def test_stats(mem_db: DBConnection): print(f"{stats=}") assert stats == { # Full on-disk size of the data file, footer and metadata included. - "total_bytes": 633, + "total_bytes": 637, "num_rows": 2, "num_indices": 0, "fragment_stats": { @@ -4228,13 +4434,14 @@ def test_refresh_column_async_returns_job(tmp_path): assert result.rows_failed == 0 assert result.rows_remaining == 0 assert result.source_version == 2 - assert result.published_version == 3 + # The fill lands at 3; the stamp recording its inputs is published at 4. + assert result.published_version == 4 assert job.status() == "finished" assert sorted(table.to_arrow()["doubled"].to_pylist()) == [2, 4] no_op = table.refresh_column_async("doubled").wait() assert no_op.rows_assigned == 0 - assert no_op.source_version == 3 + assert no_op.source_version == 4 assert no_op.published_version is None # Bad input raises at the call, not through the job. @@ -4253,6 +4460,6 @@ async def test_refresh_column_async_job_async_table(tmp_path): assert isinstance(result, lancedb.RefreshColumnResult) assert result.rows_assigned == 1 assert result.source_version == 2 - assert result.published_version == 3 + assert result.published_version == 4 assert await job.status() == "finished" assert (await table.to_arrow())["tripled"].to_pylist() == [9] diff --git a/python/src/catalog.rs b/python/src/catalog.rs new file mode 100644 index 000000000..f61b409ff --- /dev/null +++ b/python/src/catalog.rs @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::time::Duration; + +use lancedb::catalog::{ + CatalogConnection, CreateDatabaseRequest, DropDatabaseRequest, ListDatabasesRequest, +}; +use pyo3::exceptions::PyValueError; +use pyo3::{Bound, PyAny, PyRef, PyResult, Python, pyclass, pyfunction, pymethods}; + +use crate::connection::{Connection, PyClientConfig}; +use crate::error::PythonErrorExt; +use crate::runtime::future_into_py; + +#[pyclass] +pub struct Catalog { + inner: CatalogConnection, +} + +#[pymethods] +impl Catalog { + #[getter] + fn uri(&self) -> &str { + self.inner.uri() + } + + #[pyo3(signature = (name, *, exist_ok=false))] + fn create_database<'py>( + self_: PyRef<'py, Self>, + name: String, + exist_ok: bool, + ) -> PyResult> { + let inner = self_.inner.clone(); + future_into_py(self_.py(), async move { + inner + .create_database(CreateDatabaseRequest::new(name).exist_ok(exist_ok)) + .await + .map(Connection::new) + .infer_error() + }) + } + + fn connect_database<'py>(self_: PyRef<'py, Self>, name: String) -> PyResult> { + let inner = self_.inner.clone(); + future_into_py(self_.py(), async move { + inner + .connect_database(name) + .await + .map(Connection::new) + .infer_error() + }) + } + + #[pyo3(signature = (name, *, ignore_missing=false))] + fn drop_database<'py>( + self_: PyRef<'py, Self>, + name: String, + ignore_missing: bool, + ) -> PyResult> { + let inner = self_.inner.clone(); + future_into_py(self_.py(), async move { + inner + .drop_database(DropDatabaseRequest::new(name).ignore_missing(ignore_missing)) + .await + .infer_error() + }) + } + + #[pyo3(signature = (*, limit=None, page_token=None))] + fn list_databases<'py>( + self_: PyRef<'py, Self>, + limit: Option, + page_token: Option, + ) -> PyResult> { + let inner = self_.inner.clone(); + future_into_py(self_.py(), async move { + let mut request = ListDatabasesRequest::default(); + request.limit = limit; + request.page_token = page_token; + let response = inner.list_databases(request).await.infer_error()?; + Ok((response.databases, response.page_token)) + }) + } +} + +#[pyfunction] +#[pyo3(signature = (endpoint, *, api_key=None, client_config=None, sql_host_override=None, read_consistency_interval=None, oauth_config=None))] +pub fn connect_catalog( + py: Python<'_>, + endpoint: String, + api_key: Option, + client_config: Option, + sql_host_override: Option, + read_consistency_interval: Option, + oauth_config: Option, +) -> PyResult> { + let interval = read_consistency_interval + .map(Duration::try_from_secs_f64) + .transpose() + .map_err(|err| { + PyValueError::new_err(format!("Invalid read consistency interval: {err}")) + })?; + future_into_py(py, async move { + let mut builder = lancedb::connect_catalog(endpoint); + if let Some(api_key) = api_key { + builder = builder.api_key(api_key); + } + if let Some(config) = client_config { + builder = builder.client_config(config.into()); + } + if let Some(endpoint) = sql_host_override { + builder = builder.sql_host_override(endpoint); + } + if let Some(interval) = interval { + builder = builder.read_consistency_interval(interval); + } + if let Some(config) = oauth_config { + builder = builder.oauth_config(config.try_into().infer_error()?); + } + Ok(Catalog { + inner: builder.execute().await.infer_error()?, + }) + }) +} diff --git a/python/src/connection.rs b/python/src/connection.rs index 902489f4f..a972f4351 100644 --- a/python/src/connection.rs +++ b/python/src/connection.rs @@ -13,11 +13,7 @@ use crate::{ runtime::future_into_py, table::Table, }; -use arrow::{ - datatypes::Schema, - ffi_stream::ArrowArrayStreamReader, - pyarrow::{FromPyArrow, ToPyArrow}, -}; +use arrow::{datatypes::Schema, ffi_stream::ArrowArrayStreamReader, pyarrow::FromPyArrow}; use lancedb::{ connection::Connection as LanceConnection, connection::NamespaceClientPushdownOperation, @@ -28,7 +24,7 @@ use pyo3::{ Bound, FromPyObject, Py, PyAny, PyRef, PyResult, Python, exceptions::{PyRuntimeError, PyValueError}, pyclass, pyfunction, pymethods, - types::{PyDict, PyDictMethods, PyList, PyListMethods}, + types::{PyAnyMethods, PyDict, PyDictMethods, PyList}, }; #[pyclass] @@ -86,6 +82,24 @@ impl Connection { } } +fn parse_default_namespace_path(path: Option>) -> PyResult> { + match path { + Some(path) => { + if !path.is_instance_of::() { + return Err(PyValueError::new_err( + "Connection.execute_query_async default_namespace_path must be a list", + )); + } + path.extract::>().map_err(|_| { + PyValueError::new_err( + "Connection.execute_query_async default_namespace_path components must be strings", + ) + }) + } + None => Ok(vec!["public".to_string()]), + } +} + #[pymethods] impl Connection { fn __repr__(&self) -> String { @@ -108,6 +122,40 @@ impl Connection { self.get_inner().map(|inner| inner.uri().to_string()) } + #[pyo3(signature = (query, *, default_namespace_path=None))] + pub fn execute_query_async<'a>( + self_: PyRef<'a, Self>, + query: String, + default_namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let default_namespace_path = parse_default_namespace_path(default_namespace_path)?; + future_into_py(self_.py(), async move { + let operation = inner + .execute_query_async(query) + .default_namespace_path(default_namespace_path); + operation + .execute() + .await + .map(crate::sql::Query::new) + .infer_error() + }) + } + + pub fn describe_query<'a>( + self_: PyRef<'a, Self>, + query_id: uuid::Uuid, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + future_into_py(self_.py(), async move { + inner + .describe_query(query_id) + .await + .map(crate::sql::QueryDescription::from) + .infer_error() + }) + } + #[pyo3(signature = ())] pub fn get_read_consistency_interval(self_: PyRef<'_, Self>) -> PyResult> { let inner = self_.get_inner()?.clone(); @@ -333,7 +381,7 @@ impl Connection { }) } - #[pyo3(signature = (name, source, projections=None, filter=None, limit=None))] + #[pyo3(signature = (name, source, projections=None, filter=None, limit=None, with_no_data=false))] pub fn create_materialized_view( self_: PyRef<'_, Self>, name: String, @@ -341,6 +389,7 @@ impl Connection { projections: Option>, filter: Option, limit: Option, + with_no_data: bool, ) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { @@ -354,16 +403,79 @@ impl Connection { if let Some(limit) = limit { builder = builder.limit(limit); } - let view = builder.execute().await.infer_error()?; + builder = builder.with_no_data(with_no_data); + let view = Box::pin(builder.execute()).await.infer_error()?; Ok(Table::new(view.table().clone())) }) } + #[pyo3(signature = (name, source, projections=None, filter=None, limit=None, with_no_data=false))] + pub fn create_materialized_view_async( + self_: PyRef<'_, Self>, + name: String, + source: String, + projections: Option>, + filter: Option, + limit: Option, + with_no_data: bool, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + future_into_py(self_.py(), async move { + let mut builder = inner.create_materialized_view(name, source); + if let Some(projections) = projections { + builder = builder.select(projections); + } + if let Some(filter) = filter { + builder = builder.only_if(filter); + } + if let Some(limit) = limit { + builder = builder.limit(limit); + } + let job = Box::pin(builder.with_no_data(with_no_data).execute_async()) + .await + .infer_error()?; + Ok(crate::job::Job::new(job)) + }) + } + pub fn list_materialized_views(self_: PyRef<'_, Self>) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { let views = inner.list_materialized_views().await.infer_error()?; - Ok(views.into_iter().map(|view| view.name).collect::>()) + Ok(views) + }) + } + + #[pyo3(signature = (name, namespace_path=None))] + pub fn drop_materialized_view( + self_: PyRef<'_, Self>, + name: String, + namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let namespace_path = namespace_path.unwrap_or_default(); + future_into_py(self_.py(), async move { + inner + .drop_materialized_view(name, &namespace_path) + .await + .infer_error() + }) + } + + #[pyo3(signature = (name, namespace_path=None))] + pub fn drop_materialized_view_async( + self_: PyRef<'_, Self>, + name: String, + namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let namespace_path = namespace_path.unwrap_or_default(); + future_into_py(self_.py(), async move { + inner + .drop_materialized_view_async(name, &namespace_path) + .await + .infer_error() + .map(crate::job::Job::new) }) } @@ -592,9 +704,12 @@ impl Connection { }) } - pub fn job(&self, job_id: String) -> PyResult { - let inner = self.get_inner()?.clone(); - Ok(crate::job::Job::new(inner.job(job_id).infer_error()?)) + pub fn open_job(self_: PyRef<'_, Self>, job_id: String) -> PyResult> { + let inner = self_.get_inner()?.clone(); + future_into_py(self_.py(), async move { + let job = inner.open_job(&job_id).await.infer_error()?; + Ok(crate::job::Job::new(job)) + }) } pub fn create_function_async( @@ -629,6 +744,109 @@ impl Connection { }) } + pub fn list_functions(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.get_inner()?.clone(); + future_into_py(self_.py(), async move { + inner + .list_functions() + .await + .infer_error()? + .into_iter() + .map(|function| function.to_canonical_json().infer_error()) + .collect::>>() + }) + } + + pub fn drop_function( + self_: PyRef<'_, Self>, + name: String, + version: String, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + future_into_py(self_.py(), async move { + inner.drop_function(name, version).await.infer_error() + }) + } + + #[pyo3(signature = (name, value, namespace_path=None))] + pub fn create_secret( + self_: PyRef<'_, Self>, + name: String, + value: String, + namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let namespace_path = namespace_path.unwrap_or_default(); + future_into_py(self_.py(), async move { + inner + .create_secret(name, value, &namespace_path) + .await + .infer_error() + }) + } + + #[pyo3(signature = (name, value, namespace_path=None))] + pub fn alter_secret( + self_: PyRef<'_, Self>, + name: String, + value: String, + namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let namespace_path = namespace_path.unwrap_or_default(); + future_into_py(self_.py(), async move { + inner + .alter_secret(name, value, &namespace_path) + .await + .infer_error() + }) + } + + #[pyo3(signature = (namespace_path=None))] + pub fn list_secrets( + self_: PyRef<'_, Self>, + namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let namespace_path = namespace_path.unwrap_or_default(); + future_into_py(self_.py(), async move { + inner.list_secrets(&namespace_path).await.infer_error() + }) + } + + #[pyo3(signature = (name, namespace_path=None))] + pub fn drop_secret( + self_: PyRef<'_, Self>, + name: String, + namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let namespace_path = namespace_path.unwrap_or_default(); + future_into_py(self_.py(), async move { + inner.drop_secret(name, &namespace_path).await.infer_error() + }) + } + + /// Name and timestamps as a plain tuple. `SecretInfo` carries no value, so + /// there is none to filter out here. Timestamps stay integers rather than + /// going through a string, so the caller can compare two without parsing. + #[pyo3(signature = (name, namespace_path=None))] + pub fn describe_secret( + self_: PyRef<'_, Self>, + name: String, + namespace_path: Option>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let namespace_path = namespace_path.unwrap_or_default(); + future_into_py(self_.py(), async move { + let info = inner + .describe_secret(name, &namespace_path) + .await + .infer_error()?; + Ok((info.name, info.created_at_millis, info.updated_at_millis)) + }) + } + pub fn list_jobs(self_: PyRef<'_, Self>) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { @@ -640,42 +858,16 @@ impl Connection { }) } - pub fn get_job(self_: PyRef<'_, Self>, job_id: String) -> PyResult> { - let inner = self_.get_inner()?.clone(); - future_into_py(self_.py(), async move { - let description = inner.get_job(&job_id).await.infer_error()?; - Ok(description.map(crate::job::JobDescription::from)) - }) - } - pub fn cancel_job(self_: PyRef<'_, Self>, job_id: String) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { inner.cancel_job(&job_id).await.infer_error() }) } - - #[pyo3(signature = (job_id=None))] - pub fn job_history( - self_: PyRef<'_, Self>, - job_id: Option, - ) -> PyResult> { - let inner = self_.get_inner()?.clone(); - future_into_py(self_.py(), async move { - let batches = inner.job_history(job_id.as_deref()).await.infer_error()?; - Python::attach(|py| { - let list = PyList::empty(py); - for batch in batches { - list.append(batch.to_pyarrow(py)?)?; - } - Ok(list.unbind()) - }) - }) - } } #[pyfunction] -#[pyo3(signature = (uri, api_key=None, region=None, host_override=None, read_consistency_interval=None, client_config=None, storage_options=None, session=None, manifest_enabled=false, namespace_client_properties=None, oauth_config=None))] +#[pyo3(signature = (uri, api_key=None, region=None, host_override=None, sql_host_override=None, read_consistency_interval=None, client_config=None, storage_options=None, session=None, manifest_enabled=false, namespace_client_properties=None, oauth_config=None))] #[allow(clippy::too_many_arguments)] pub fn connect( py: Python<'_>, @@ -683,6 +875,7 @@ pub fn connect( api_key: Option, region: Option, host_override: Option, + sql_host_override: Option, read_consistency_interval: Option, client_config: Option, storage_options: Option>, @@ -702,6 +895,12 @@ pub fn connect( if let Some(host_override) = host_override { builder = builder.host_override(&host_override); } + #[cfg(feature = "remote")] + if let Some(sql_host_override) = sql_host_override { + builder = builder.sql_host_override(&sql_host_override); + } + #[cfg(not(feature = "remote"))] + let _ = sql_host_override; if let Some(read_consistency_interval) = read_consistency_interval { let read_consistency_interval = Duration::from_secs_f64(read_consistency_interval); builder = builder.read_consistency_interval(read_consistency_interval); diff --git a/python/src/error.rs b/python/src/error.rs index b66afe47b..869a5a247 100644 --- a/python/src/error.rs +++ b/python/src/error.rs @@ -29,7 +29,10 @@ impl PythonErrorExt for std::result::Result { LanceError::InvalidInput { .. } | LanceError::InvalidTableName { .. } | LanceError::TableNotFound { .. } + | LanceError::NotAMaterializedView { .. } | LanceError::Schema { .. } + | LanceError::DatabaseNotFound { .. } + | LanceError::DatabaseAlreadyExists { .. } | LanceError::TableAlreadyExists { .. } => self.value_error(), LanceError::CreateDir { .. } => self.os_error(), LanceError::ObjectStore { .. } => Err(PyIOError::new_err(err.to_string())), @@ -114,6 +117,12 @@ impl PythonErrorExt for std::result::Result { .getattr(intern!(py, "JobCancelledError"))?; Err(PyErr::from_value(cls.call1((err.to_string(),))?)) }), + LanceError::JobNotFound { .. } => Python::attach(|py| { + let cls = py + .import(intern!(py, "lancedb.exceptions"))? + .getattr(intern!(py, "JobNotFoundError"))?; + Err(PyErr::from_value(cls.call1((err.to_string(),))?)) + }), _ => self.runtime_error(), }, } diff --git a/python/src/job.rs b/python/src/job.rs index 688cba7f9..e22b2f897 100644 --- a/python/src/job.rs +++ b/python/src/job.rs @@ -4,11 +4,50 @@ use std::sync::Arc; use crate::runtime::future_into_py; -use pyo3::{Bound, PyAny, PyRef, PyResult, pyclass, pymethods}; +use arrow::{ + datatypes::Schema, + pyarrow::{IntoPyArrow, Table as PyArrowTable}, +}; +use lancedb::job::JobEventsRequest; +use pyo3::{ + Bound, PyAny, PyRef, PyResult, Python, + exceptions::PyValueError, + pyclass, pymethods, + types::{PyAnyMethods, PyDict, PyDictMethods}, +}; use serde::Serialize; use crate::error::PythonErrorExt; +const REPR_INDENT: &str = " "; + +/// Parse a stored JSON payload into Python data. The bindings carry these as +/// strings because that is what crosses the boundary cheaply; the public +/// Python surface is the parsed form. +fn parse_json_payload<'py>( + py: Python<'py>, + raw: Option<&str>, +) -> PyResult>> { + match raw { + None => Ok(None), + Some(raw) => Ok(Some(py.import("json")?.call_method1("loads", (raw,))?)), + } +} + +/// A payload rendered as indented JSON, aligned under the field that holds it. +fn pretty_json_payload(py: Python<'_>, raw: Option<&str>) -> PyResult> { + let Some(parsed) = parse_json_payload(py, raw)? else { + return Ok(None); + }; + let kwargs = PyDict::new(py); + kwargs.set_item("indent", 4)?; + let rendered: String = py + .import("json")? + .call_method("dumps", (parsed,), Some(&kwargs))? + .extract()?; + Ok(Some(rendered.replace('\n', &format!("\n{REPR_INDENT}")))) +} + #[pyclass] pub struct Job { inner: Arc, String>>>, @@ -67,10 +106,52 @@ impl Job { Ok(()) }) } + + pub fn refresh(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner.clone(); + future_into_py(self_.py(), async move { + inner.refresh().await.infer_error()?; + Ok(()) + }) + } + + /// The last observed lifecycle state, without contacting the backend. + #[getter] + pub fn _state(&self) -> Option { + self.inner.state() + } + + /// The last observed server-side record. `None` for an in-process job. + #[getter] + pub fn _description(&self) -> Option { + self.inner.description().map(JobDescription::from) + } + + #[pyo3(signature = (*, limit=None, filter=None))] + pub fn events( + self_: PyRef<'_, Self>, + limit: Option, + filter: Option, + ) -> PyResult> { + let inner = self_.inner.clone(); + let request = JobEventsRequest { limit, filter }; + future_into_py(self_.py(), async move { + let batches = inner.events(request).await.infer_error()?; + Python::attach(|py| { + let schema = batches + .first() + .map(|batch| batch.schema()) + .unwrap_or_else(|| Arc::new(Schema::empty())); + let table = PyArrowTable::try_new(batches, schema) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + table.into_pyarrow(py).map(|table| table.unbind()) + }) + }) + } } /// A row from `Connection.list_jobs`: one server-side job. -#[pyclass(get_all, skip_from_py_object)] +#[pyclass(module = "lancedb._lancedb", get_all, skip_from_py_object)] #[derive(Clone)] pub struct JobInfo { job_id: String, @@ -103,7 +184,7 @@ impl From for JobInfo { } /// The server's account of why a job failed. -#[pyclass(get_all, skip_from_py_object)] +#[pyclass(module = "lancedb._lancedb", get_all, skip_from_py_object)] #[derive(Clone)] pub struct JobFailureInfo { phase: Option, @@ -121,25 +202,57 @@ impl JobFailureInfo { } } -/// A described job from `Connection.get_job`. -#[pyclass(get_all, skip_from_py_object)] +/// The server-side record behind a `Job` handle. +#[pyclass(module = "lancedb._lancedb", get_all, skip_from_py_object)] #[derive(Clone)] pub struct JobDescription { job_id: String, job_type: String, state: String, creation_ms: i64, - spec_json: Option, + /// Internal: the wire form behind the `spec` property. + _spec_json: Option, + /// Internal: the wire form behind the `result` property. + _result_json: Option, failure: Option, } #[pymethods] impl JobDescription { - fn __repr__(&self) -> String { - format!( - "JobDescription(job_id={:?}, job_type={:?}, state={:?}, creation_ms={})", - self.job_id, self.job_type, self.state, self.creation_ms - ) + /// The job-type-specific specification it was submitted with. + #[getter] + fn spec<'py>(&self, py: Python<'py>) -> PyResult>> { + parse_json_payload(py, self._spec_json.as_deref()) + } + + /// The job-type-specific terminal result. `None` until the job succeeds. + #[getter] + fn result<'py>(&self, py: Python<'py>) -> PyResult>> { + parse_json_payload(py, self._result_json.as_deref()) + } + + fn __repr__(&self, py: Python<'_>) -> PyResult { + let mut fields = vec![ + format!("job_id={:?}", self.job_id), + format!("job_type={:?}", self.job_type), + format!("state={:?}", self.state), + format!("creation_ms={}", self.creation_ms), + ]; + // Lay the payloads out as indented JSON, the same way the `Job` repr + // does, so the two agree on how the same data looks. + for (name, payload) in [("spec", &self._spec_json), ("result", &self._result_json)] { + if let Some(rendered) = pretty_json_payload(py, payload.as_deref())? { + fields.push(format!("{name}={rendered}")); + } + } + if let Some(failure) = &self.failure { + fields.push(format!("failure={}", failure.__repr__())); + } + let body = fields + .iter() + .map(|field| format!("\n{REPR_INDENT}{field},")) + .collect::(); + Ok(format!("JobDescription({body}\n)")) } } @@ -150,7 +263,11 @@ impl From for JobDescription { job_type: description.job_type, state: description.state, creation_ms: description.creation_ms, - spec_json: (!description.spec.is_null()).then(|| description.spec.to_string()), + _spec_json: (!description.spec.is_null()).then(|| description.spec.to_string()), + _result_json: description + .result + .filter(|result| !result.is_null()) + .map(|result| result.to_string()), failure: description.failure.map(|failure| JobFailureInfo { phase: failure.phase, message: failure.message, diff --git a/python/src/lib.rs b/python/src/lib.rs index 8d3eab787..fd9a23798 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -8,8 +8,8 @@ use expr::{PyExpr, expr_col, expr_func, expr_lit}; use index::IndexConfig; use permutation::{PyAsyncPermutationBuilder, PyPermutationReader}; use pyo3::{ - Bound, PyResult, Python, pymodule, - types::{PyModule, PyModuleMethods}, + Bound, PyResult, Python, pyfunction, pymodule, + types::{PyAnyMethods, PyModule, PyModuleMethods}, wrap_pyfunction, }; use query::{FTSQuery, HybridQuery, Query, VectorQuery}; @@ -21,6 +21,7 @@ use table::{ }; pub mod arrow; +pub mod catalog; pub mod connection; pub mod error; pub mod expr; @@ -34,22 +35,46 @@ pub mod permutation; pub mod query; pub mod runtime; pub mod session; +pub mod sql; pub mod table; pub mod util; +/// Shut down the shared Tokio runtime (see `runtime::shutdown`). +/// +/// Registered below as a Python `atexit` callback rather than called +/// directly: `atexit` runs while the interpreter is still fully valid, +/// which is the coordinated, bounded exit the runtime otherwise never gets. +/// +/// Runs the actual wait with the GIL released (`Python::detach`): shutdown +/// blocks the calling thread waiting on the runtime's own worker threads, +/// and if any in-flight task needs the GIL to finish (e.g. one that calls +/// back into Python), holding it here while waiting on that same task would +/// deadlock rather than time out. +#[pyfunction] +fn shutdown_runtime(py: Python<'_>) { + py.detach(|| runtime::shutdown(std::time::Duration::from_secs(5))); +} + #[pymodule] -pub fn _lancedb(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { +pub fn _lancedb(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { let env = Env::new() .filter_or("LANCEDB_LOG", "warn") .write_style("LANCEDB_LOG_STYLE"); env_logger::init_from_env(env); m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(catalog::connect_catalog, m)?)?; m.add_class::()?; m.add_class::
()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -92,5 +117,9 @@ pub fn _lancedb(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(expr_lit, m)?)?; m.add_function(wrap_pyfunction!(expr_func, m)?)?; m.add("__version__", env!("CARGO_PKG_VERSION"))?; + // Give the shared runtime a coordinated, bounded shutdown at normal + // process exit -- see `shutdown_runtime` and `runtime::shutdown` for why. + py.import("atexit")? + .call_method1("register", (wrap_pyfunction!(shutdown_runtime, m)?,))?; Ok(()) } diff --git a/python/src/oauth.rs b/python/src/oauth.rs index 11ea011e2..1639f239a 100644 --- a/python/src/oauth.rs +++ b/python/src/oauth.rs @@ -1,10 +1,33 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors -use pyo3::FromPyObject; +use std::path::PathBuf; +use std::sync::Arc; +use pyo3::{FromPyObject, PyResult, Python, pyclass, pymethods}; + +use crate::error::PythonErrorExt; +use crate::runtime::future_into_py; use lancedb::error::Error; -use lancedb::remote::oauth::{OAuthConfig, OAuthFlow}; +use lancedb::remote::oauth::{AuthorizationCodeOptions, ClientAuthMethod, OAuthConfig, OAuthFlow}; +use lancedb::remote::{OAuthSession, SessionLogout, SessionStatus, TokenCacheOptions}; + +/// Python-side persistent token cache options, extracted via FromPyObject. +/// Maps to `lancedb.remote.oauth.TokenCacheOptions` Python dataclass. +#[derive(FromPyObject, Default)] +pub struct PyTokenCacheOptions { + pub cache_dir: Option, + pub lock_timeout_secs: Option, +} + +impl From for TokenCacheOptions { + fn from(py: PyTokenCacheOptions) -> Self { + Self { + cache_dir: py.cache_dir.map(PathBuf::from), + lock_timeout_secs: py.lock_timeout_secs, + } + } +} /// Python-side OAuth configuration, extracted via FromPyObject. /// Maps to `lancedb.remote.oauth.OAuthConfig` Python dataclass. @@ -13,10 +36,19 @@ pub struct PyOAuthConfig { pub issuer_url: String, pub client_id: String, pub scopes: Vec, + /// Optional resource indicator for authorization and token requests. + pub resource: Option, + /// Optional provider-specific audience for authorization and token requests. + pub audience: Option, pub flow: String, pub client_secret: Option, + pub client_auth_method: Option, + pub redirect_uri: Option, + pub callback_port: Option, + pub use_pkce: bool, pub managed_identity_client_id: Option, pub refresh_buffer_secs: Option, + pub token_cache: Option, } impl TryFrom for OAuthConfig { @@ -25,6 +57,17 @@ impl TryFrom for OAuthConfig { fn try_from(py: PyOAuthConfig) -> Result { let flow = match py.flow.as_str() { "client_credentials" => OAuthFlow::ClientCredentials, + "authorization_code" => { + let mut options = AuthorizationCodeOptions::new().use_pkce(py.use_pkce); + if let Some(redirect_uri) = py.redirect_uri { + options = options.redirect_uri(redirect_uri); + } + if let Some(callback_port) = py.callback_port { + options = options.callback_port(callback_port); + } + OAuthFlow::AuthorizationCode(options) + } + "device_code" => OAuthFlow::DeviceCode, "azure_managed_identity" => OAuthFlow::AzureManagedIdentity { client_id: py.managed_identity_client_id, }, @@ -35,13 +78,182 @@ impl TryFrom for OAuthConfig { } }; + let client_auth_method = match py.client_auth_method.as_deref() { + Some("none") => Some(ClientAuthMethod::None), + Some("client_secret_basic") => Some(ClientAuthMethod::ClientSecretBasic), + Some("client_secret_post") => Some(ClientAuthMethod::ClientSecretPost), + None => None, + Some(other) => { + return Err(Error::InvalidInput { + message: format!("Unknown OAuth client auth method: {other}"), + }); + } + }; + Ok(Self { issuer_url: py.issuer_url, client_id: py.client_id, client_secret: py.client_secret, + client_auth_method, scopes: py.scopes, + resource: py.resource, + audience: py.audience, flow, refresh_buffer_secs: py.refresh_buffer_secs, + token_cache: py.token_cache.map(TokenCacheOptions::from), + }) + } +} + +/// Wrapper around [`lancedb::remote::SessionStatus`] exposing safe metadata. +#[pyclass(name = "SessionStatus", skip_from_py_object)] +#[derive(Clone)] +pub struct PySessionStatus { + inner: SessionStatus, +} + +#[pymethods] +impl PySessionStatus { + /// Whether a cached session exists that can obtain tokens without + /// interactive authentication. + #[getter] + pub fn refreshable(&self) -> bool { + self.inner.refreshable + } + + /// Canonical issuer URL of the cached session. + #[getter] + pub fn issuer_url(&self) -> String { + self.inner.issuer_url.clone() + } + + /// Client ID of the cached session. + #[getter] + pub fn client_id(&self) -> String { + self.inner.client_id.clone() + } + + /// Canonical (sorted, de-duplicated) scopes of the cached session. + #[getter] + pub fn scopes(&self) -> Vec { + self.inner.scopes.clone() + } + + /// Resource indicator used to obtain the cached session. + #[getter] + pub fn resource(&self) -> Option { + self.inner.resource.clone() + } + + /// Provider-specific audience used to obtain the cached session. + #[getter] + pub fn audience(&self) -> Option { + self.inner.audience.clone() + } + + /// Flow that produced the cached session. + #[getter] + pub fn flow(&self) -> String { + self.inner.flow.clone() + } + + /// When the cached session was obtained, as Unix seconds. + #[getter] + pub fn obtained_at(&self) -> Option { + self.inner.obtained_at + } + + pub fn __repr__(&self) -> String { + format!( + "SessionStatus(refreshable={}, issuer_url='{}', client_id='{}', flow='{}')", + self.inner.refreshable, self.inner.issuer_url, self.inner.client_id, self.inner.flow + ) + } +} + +impl From for PySessionStatus { + fn from(inner: SessionStatus) -> Self { + Self { inner } + } +} + +/// Wrapper around [`lancedb::remote::SessionLogout`]. +#[pyclass(name = "SessionLogout", skip_from_py_object)] +#[derive(Clone)] +pub struct PySessionLogout { + inner: SessionLogout, +} + +#[pymethods] +impl PySessionLogout { + /// Whether a cached credential was removed. + #[getter] + pub fn removed(&self) -> bool { + self.inner.removed + } + + pub fn __repr__(&self) -> String { + format!("SessionLogout(removed={})", self.inner.removed) + } +} + +impl From for PySessionLogout { + fn from(inner: SessionLogout) -> Self { + Self { inner } + } +} + +/// Wrapper around [`lancedb::remote::OAuthSession`]. +#[pyclass(name = "OAuthSession", skip_from_py_object)] +#[derive(Clone)] +pub struct PyOAuthSession { + inner: Arc, +} + +#[pymethods] +impl PyOAuthSession { + /// Create a session manager for the given OAuth configuration. + /// + /// The configuration must set ``token_cache`` options and use a flow that + /// supports persistent sessions (authorization code or device code). + #[new] + pub fn new(config: PyOAuthConfig) -> PyResult { + let config: OAuthConfig = config.try_into().infer_error()?; + let inner = OAuthSession::new(config).infer_error()?; + Ok(Self { + inner: Arc::new(inner), + }) + } + + /// Eagerly run the configured authentication flow and store the session. + pub fn login<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner.login().await.map(PySessionStatus::from).infer_error() + }) + } + + /// Report whether a matching cached session exists, with safe metadata. + pub fn status<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner + .status() + .await + .map(PySessionStatus::from) + .infer_error() + }) + } + + /// Remove the matching local cached credential. + pub fn logout<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = Arc::clone(&self.inner); + future_into_py(py, async move { + inner + .logout() + .await + .map(PySessionLogout::from) + .infer_error() }) } } @@ -50,16 +262,30 @@ impl TryFrom for OAuthConfig { mod tests { use super::*; - #[test] - fn test_unknown_oauth_flow_returns_invalid_input() { - let config = PyOAuthConfig { + fn base_config() -> PyOAuthConfig { + PyOAuthConfig { issuer_url: "https://issuer.example.com".to_string(), client_id: "client-id".to_string(), scopes: vec!["scope".to_string()], - flow: "typo".to_string(), + flow: "device_code".to_string(), client_secret: None, + client_auth_method: None, + redirect_uri: None, + callback_port: None, + use_pkce: true, managed_identity_client_id: None, refresh_buffer_secs: None, + resource: None, + audience: None, + token_cache: None, + } + } + + #[test] + fn test_unknown_oauth_flow_returns_invalid_input() { + let config = PyOAuthConfig { + flow: "typo".to_string(), + ..base_config() }; let err = OAuthConfig::try_from(config).unwrap_err(); @@ -69,4 +295,91 @@ mod tests { if message == "Unknown OAuth flow type: typo" )); } + + #[test] + fn test_authorization_code_conversion_preserves_options() { + let config = PyOAuthConfig { + flow: "authorization_code".to_string(), + client_secret: Some("secret".to_string()), + redirect_uri: Some("http://127.0.0.1:9000/callback".to_string()), + callback_port: Some(9000), + use_pkce: false, + resource: Some("urn:resource".into()), + audience: Some("audience".into()), + ..base_config() + }; + + let converted = OAuthConfig::try_from(config).unwrap(); + let OAuthFlow::AuthorizationCode(options) = converted.flow else { + panic!("expected authorization code flow"); + }; + assert_eq!( + options.redirect_uri.as_deref(), + Some("http://127.0.0.1:9000/callback") + ); + assert_eq!(options.callback_port, Some(9000)); + assert!(!options.use_pkce); + assert_eq!(converted.resource.as_deref(), Some("urn:resource")); + assert_eq!(converted.audience.as_deref(), Some("audience")); + } + + #[test] + fn test_device_code_conversion() { + let config = base_config(); + let converted = OAuthConfig::try_from(config).unwrap(); + assert!(matches!(converted.flow, OAuthFlow::DeviceCode)); + } + + #[test] + fn test_client_auth_method_conversion() { + for (value, expected) in [ + ("none", ClientAuthMethod::None), + ("client_secret_basic", ClientAuthMethod::ClientSecretBasic), + ("client_secret_post", ClientAuthMethod::ClientSecretPost), + ] { + let config = PyOAuthConfig { + client_auth_method: Some(value.to_string()), + ..base_config() + }; + + let converted = OAuthConfig::try_from(config).unwrap(); + assert_eq!(converted.client_auth_method, Some(expected)); + } + } + + #[test] + fn test_unknown_client_auth_method_returns_invalid_input() { + let config = PyOAuthConfig { + client_auth_method: Some("typo".to_string()), + ..base_config() + }; + + let err = OAuthConfig::try_from(config).unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message == "Unknown OAuth client auth method: typo" + )); + } + + #[test] + fn test_token_cache_conversion() { + let config = PyOAuthConfig { + resource: None, + audience: None, + token_cache: Some(PyTokenCacheOptions { + cache_dir: Some("/tmp/oauth-cache".to_string()), + lock_timeout_secs: Some(5), + }), + ..base_config() + }; + + let converted = OAuthConfig::try_from(config).unwrap(); + let cache = converted.token_cache.expect("token cache options"); + assert_eq!( + cache.cache_dir.as_deref(), + Some(std::path::Path::new("/tmp/oauth-cache")) + ); + assert_eq!(cache.lock_timeout_secs, Some(5)); + } } diff --git a/python/src/query.rs b/python/src/query.rs index 38153729f..2398376d5 100644 --- a/python/src/query.rs +++ b/python/src/query.rs @@ -323,6 +323,7 @@ impl<'py> IntoPyObject<'py> for PyQueryVectors { pub struct PyQueryRequest { pub limit: Option, pub offset: Option, + pub take_offsets: Option>, pub filter: Option, pub full_text_search: Option>, pub select: PySelect, @@ -333,7 +334,7 @@ pub struct PyQueryRequest { pub column: Option, pub query_vector: Option, pub minimum_nprobes: Option, - // None means user did not set it and default shoud be used (currenty 20) + // None means user did not set it and default should be used (currently 20) // Some(0) means user set it to None and there is no limit pub maximum_nprobes: Option, pub lower_bound: Option, @@ -353,6 +354,7 @@ impl From for PyQueryRequest { AnyQuery::Query(query_request) => Self { limit: query_request.limit, offset: query_request.offset, + take_offsets: query_request.take_offsets, filter: query_request.filter.map(PyQueryFilter), full_text_search: query_request .full_text_search @@ -381,6 +383,7 @@ impl From for PyQueryRequest { AnyQuery::VectorQuery(vector_query) => Self { limit: vector_query.base.limit, offset: vector_query.base.offset, + take_offsets: vector_query.base.take_offsets, filter: vector_query.base.filter.map(PyQueryFilter), full_text_search: None, select_source_columns: PySelect::source_columns(&vector_query.base.select), diff --git a/python/src/runtime.rs b/python/src/runtime.rs index 13951a7aa..170f7e592 100644 --- a/python/src/runtime.rs +++ b/python/src/runtime.rs @@ -4,20 +4,84 @@ //! Fork-safe wrapper around tokio + pyo3-async-runtimes. //! //! `pyo3_async_runtimes::tokio` keeps its multi-threaded runtime in a -//! `OnceLock` that can never be replaced. Tokio's worker threads do not +//! `OnceLock` that can never be replaced. Tokio's worker threads do not //! survive `fork()`, so once a child inherits a "frozen" runtime, every -//! `future_into_py` call hangs forever. +//! `future_into_py` call hangs forever. Normal (non-fork) process exit has +//! its own gap: nothing tells the runtime to shut down, so its worker +//! threads keep running, uncoordinated with the interpreter, right up until +//! the process ends. If one of them is mid-task exactly as `Py_Finalize` +//! starts tearing down interpreter state, it can panic on state that's +//! already gone -- and since that happens on a background thread with no +//! PyO3-wrapped call frame to catch it, Rust aborts the whole process +//! rather than failing that one call. [`shutdown`], registered as a Python +//! `atexit` callback, closes that gap by giving the runtime a coordinated, +//! bounded exit while the interpreter is still fully valid. //! -//! We sidestep the global by routing every future through our own -//! [`LanceRuntime`] (a [`pyo3_async_runtimes::generic::Runtime`] impl) backed -//! by an [`AtomicPtr`] to a tokio runtime that we own. A `pthread_atfork` -//! child handler nulls the pointer; the next `spawn` rebuilds the runtime in -//! the child. This mirrors the pattern used in the Lance Python bindings. +//! Getting both of these right at once took a few tries; the design here +//! rests on three separate mechanisms, each solving one problem the others +//! cannot: +//! +//! **`OUTSTANDING`, not `Arc::strong_count`, decides when the runtime is +//! idle.** Early versions tried to infer "is anything still using this +//! runtime" from how many `Arc` clones existed. That signal is +//! wrong in both directions: a clone taken only for the instant a task is +//! *submitted* says nothing about whether that task has actually finished +//! running (`Runtime::shutdown_timeout` gives spawned, non-blocking tasks +//! no grace period at all -- a task "keeps running until it yields, then is +//! dropped" -- so a reclaim landing right after submission would silently +//! abandon it before it ever got to run); and a clone held for a task's +//! *whole* lifetime can end up making that task the final owner of the +//! `Runtime`, so completing it drops the `Runtime` from inside one of its +//! own worker threads, which tokio itself forbids ("cannot drop a runtime +//! in a context where blocking is not allowed") and panics. `OUTSTANDING` +//! is an explicit courtesy counter instead: every top-level `spawn`, +//! `spawn_blocking`, or `block_on` call increments it before it starts and +//! decrements it (via [`OutstandingGuard`]) when it is truly done, entirely +//! decoupled from how many `Arc` clones exist at any instant. `shutdown` +//! waits for it to reach zero before ever touching the runtime, which also +//! closes a narrower race: because the counter is incremented *before* +//! `get_runtime()` is even called, an install already in progress when +//! `shutdown` runs is never invisible to it the way an empty slot would be. +//! If the counter never reaches zero within the bound, `shutdown` stops +//! waiting and forces the retirement attempt anyway -- silently returning +//! with the runtime and its workers still fully alive would just recreate +//! the exact race this function exists to close, for any call slower than +//! the grace period. +//! +//! **Tasks never hold an `Arc`.** Because `OUTSTANDING` (not +//! reference counting) is what `shutdown` waits on, a top-level task only +//! needs to carry an `OutstandingGuard` -- a token whose `Drop` is a plain +//! atomic decrement -- not a clone of the runtime itself. That is what +//! makes it impossible for a task's completion to become the final, +//! worker-thread-side drop of the `Runtime`: nothing a task holds ever +//! *is* the `Runtime`. +//! +//! **`atfork_child` touches nothing but a plain counter.** `future_into_py` +//! spawns a task that, once running, spawns a second one to do the real +//! work and awaits its `JoinHandle`; if a nested call re-resolved "the +//! current runtime" independently, a reclaim landing between the two calls +//! could bind them to different instances. `spawn`/`spawn_blocking` close +//! that with `Handle::try_current`: a call already running on one of our +//! worker threads stays pinned to that instance, so only the first, +//! outermost call of a chain ever consults [`get_runtime`]. That leaves +//! fork as the other place identity can change, and it has to be handled +//! without ever calling into `ArcSwapOption` from the child handler itself +//! -- `swap`/`compare_and_swap` reconcile reader "debts" internally (via +//! thread-local state, and potentially an allocation), none of which is +//! safe to run in a forked child that may have inherited another thread's +//! lock mid-acquisition. `atfork_child` therefore does nothing but bump a +//! bare `GENERATION` counter; [`get_runtime`] compares the generation its +//! installed runtime was built in against the live counter on every call, +//! from ordinary (non-signal) context, and treats a mismatch as "stale, +//! rebuild" -- exactly the check `atfork_child` used to perform directly. use std::future::Future; use std::pin::Pin; -use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; +use arc_swap::ArcSwapOption; use pyo3::{Bound, PyAny, PyResult, Python, conversion::IntoPyObject}; use pyo3_async_runtimes::{ TaskLocals, @@ -25,8 +89,31 @@ use pyo3_async_runtimes::{ }; use tokio::{runtime, task}; -static RUNTIME: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); -static RUNTIME_INSTALLING: AtomicBool = AtomicBool::new(false); +/// A runtime tagged with the fork generation it was built in, so a stale +/// (post-fork, dead-worker-threads) instance can be told apart from a live +/// one without `atfork_child` ever having to touch it directly. +struct Tagged { + runtime: runtime::Runtime, + generation: u64, +} + +impl std::ops::Deref for Tagged { + type Target = runtime::Runtime; + fn deref(&self) -> &runtime::Runtime { + &self.runtime + } +} + +static RUNTIME: ArcSwapOption = ArcSwapOption::const_empty(); +/// Bumped only by `atfork_child`, and only ever read elsewhere. This is the +/// entire fork-safety mechanism: no lock, no allocation, no thread-local +/// access -- just one atomic add, which is all a `pthread_atfork` child +/// handler is ever safe to do. +static GENERATION: AtomicU64 = AtomicU64::new(0); +/// Count of top-level `spawn`/`spawn_blocking`/`block_on` calls that have +/// started but not yet finished. See the module docs for why this, and not +/// `Arc::strong_count`, is what `shutdown` waits on. +static OUTSTANDING: AtomicU64 = AtomicU64::new(0); static ATFORK_INSTALLED: AtomicBool = AtomicBool::new(false); fn create_runtime() -> runtime::Runtime { @@ -37,23 +124,67 @@ fn create_runtime() -> runtime::Runtime { .expect("Failed to build tokio runtime") } -fn get_runtime() -> &'static runtime::Runtime { +/// Get a live, owned handle to the shared runtime, rebuilding it if the +/// installed one predates the most recent `fork()`. +fn get_runtime() -> Arc { + let current_gen = GENERATION.load(Ordering::SeqCst); loop { - let ptr = RUNTIME.load(Ordering::SeqCst); - if !ptr.is_null() { - return unsafe { &*ptr }; + let existing = RUNTIME.load_full(); + if let Some(existing) = &existing + && existing.generation == current_gen + { + return Arc::clone(existing); } - if !RUNTIME_INSTALLING.fetch_or(true, Ordering::SeqCst) { - break; + if !ATFORK_INSTALLED.fetch_or(true, Ordering::SeqCst) { + install_atfork(); } - std::thread::yield_now(); + // Built optimistically, outside any lock: on the rare race where two + // threads both find the slot empty (or stale), one candidate wins + // the compare-and-swap below and the other is simply dropped here, + // tearing down its own (never shared, never used) worker pool the + // ordinary way. + let candidate = Arc::new(Tagged { + runtime: create_runtime(), + generation: current_gen, + }); + let previous = RUNTIME.compare_and_swap(&existing, Some(Arc::clone(&candidate))); + let won = match (&*previous, &existing) { + (None, None) => true, + (Some(prev), Some(exist)) => Arc::ptr_eq(prev, exist), + _ => false, + }; + if won { + if let Some(stale) = existing { + // A prior generation's runtime: its worker threads are dead + // in this process (they do not survive fork), so dropping it + // normally would try to join them and hang. Leak it instead. + std::mem::forget(stale); + } + return candidate; + } + // Someone else's candidate (or a concurrent shutdown) won; go around + // and reload. } - if !ATFORK_INSTALLED.fetch_or(true, Ordering::SeqCst) { - install_atfork(); +} + +/// RAII token tracked by [`OUTSTANDING`]. Held for the duration of a +/// top-level `block_on` call, or moved into a top-level `spawn`/ +/// `spawn_blocking` task so it decrements only once that task's *entire* +/// body -- including anything it nested-spawns and awaits -- has run to +/// completion or been dropped without completing. +struct OutstandingGuard; + +impl OutstandingGuard { + fn new() -> Self { + OUTSTANDING.fetch_add(1, Ordering::SeqCst); + Self + } +} + +impl Drop for OutstandingGuard { + fn drop(&mut self) { + OUTSTANDING.fetch_sub(1, Ordering::SeqCst); } - let new_ptr = Box::into_raw(Box::new(create_runtime())); - RUNTIME.store(new_ptr, Ordering::SeqCst); - unsafe { &*new_ptr } } /// Block the current thread on a future using the shared runtime. @@ -62,16 +193,78 @@ fn get_runtime() -> &'static runtime::Runtime { /// building a namespace client). Must not be called from within the runtime's /// own worker threads. pub fn block_on(fut: F) -> F::Output { + let _guard = OutstandingGuard::new(); get_runtime().block_on(fut) } -/// Runs in async-signal context after `fork()` in the child. We can only -/// touch atomics here; we deliberately leak the previous runtime because -/// dropping a tokio `Runtime` would try to join its (now-dead) worker -/// threads and hang. +/// Gracefully quiesce the shared runtime, meant to run at normal process exit. +/// +/// Waits (bounded by `timeout`) for [`OUTSTANDING`] to reach zero -- i.e. +/// for every top-level call already under way to actually finish, not just +/// for `Arc::strong_count` to look low -- before ever touching the runtime. +/// If the bound elapses first, it stops waiting and attempts retirement +/// anyway: leaving the runtime and its worker threads untouched would just +/// recreate the exact race this function exists to close, for any call +/// slower than the grace period. +/// +/// Retirement itself removes the runtime from the slot and calls +/// `shutdown_timeout` rather than a bare `drop`: dropping a tokio `Runtime` +/// waits (in the worst case indefinitely) for its worker threads to join, +/// whereas `shutdown_timeout` gives real in-flight work -- a connection +/// pool's keep-alive, a graceful close -- a bounded chance to finish first, +/// then forcibly ends whatever has not. Reclaiming can only proceed once +/// `Arc::try_unwrap` proves no other reference remains; if some transient +/// `get_runtime()` caller is, at that exact instant, still between loading +/// the slot and finishing its own call, this abandons the runtime instead +/// of forcing the issue -- the same trade `atfork_child` already makes. +/// +/// Neither of the two ways this can fail to cleanly retire the runtime -- +/// the wait timing out, or `try_unwrap` losing that race -- has any other +/// signal to report through (`shutdown_timeout` itself returns nothing), +/// so both log a warning instead of failing silently. +pub fn shutdown(timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + let outstanding = OUTSTANDING.load(Ordering::SeqCst); + if outstanding == 0 { + break; + } + if Instant::now() >= deadline { + log::warn!( + "lancedb: runtime shutdown timed out with {outstanding} call(s) still in flight; forcing shutdown anyway, some in-flight work may be abandoned" + ); + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + let Some(current) = RUNTIME.load_full() else { + return; + }; + RUNTIME.compare_and_swap(&Some(Arc::clone(¤t)), None); + match Arc::try_unwrap(current) { + Ok(tagged) => { + tagged + .runtime + .shutdown_timeout(deadline.saturating_duration_since(Instant::now())); + } + Err(_) => { + // Some transient `get_runtime()` caller is, at this exact + // instant, still between loading the slot and finishing its own + // call: we have no owned handle to call `shutdown_timeout` on, + // and no way to force one (tokio has no shutdown API over a + // shared reference). Nothing more to do but say so. + log::warn!( + "lancedb: runtime shutdown could not obtain exclusive ownership; the shared runtime was left running" + ); + } + } +} + +/// Runs in async-signal context after `fork()` in the child. Touches +/// nothing but a plain atomic add -- see the module docs for why even +/// `ArcSwapOption::swap` is not safe to call here. extern "C" fn atfork_child() { - RUNTIME.store(std::ptr::null_mut(), Ordering::SeqCst); - RUNTIME_INSTALLING.store(false, Ordering::SeqCst); + GENERATION.fetch_add(1, Ordering::SeqCst); } #[cfg(not(windows))] @@ -102,11 +295,26 @@ impl Runtime for LanceRuntime { type JoinError = LanceJoinError; type JoinHandle = Pin> + Send>>; + /// `pyo3_async_runtimes::generic::future_into_py` spawns a task that, + /// once it starts running, spawns a second one to do the real work and + /// awaits its `JoinHandle`. `Handle::try_current` pins that nested call + /// to whatever runtime is already executing it, so only the first, + /// outermost call of a chain -- one running on a thread outside any + /// runtime -- ever consults [`get_runtime`] or [`OUTSTANDING`]. fn spawn(fut: F) -> Self::JoinHandle where F: Future + Send + 'static, { - let handle = get_runtime().spawn(fut); + let handle = match tokio::runtime::Handle::try_current() { + Ok(handle) => handle.spawn(fut), + Err(_) => { + let guard = OutstandingGuard::new(); + get_runtime().spawn(async move { + let _guard = guard; + fut.await; + }) + } + }; Box::pin(async move { handle.await.map_err(LanceJoinError) }) } @@ -114,7 +322,16 @@ impl Runtime for LanceRuntime { where F: FnOnce() + Send + 'static, { - let handle = get_runtime().spawn_blocking(f); + let handle = match tokio::runtime::Handle::try_current() { + Ok(handle) => handle.spawn_blocking(f), + Err(_) => { + let guard = OutstandingGuard::new(); + get_runtime().spawn_blocking(move || { + let _guard = guard; + f(); + }) + } + }; Box::pin(async move { handle.await.map_err(LanceJoinError) }) } } @@ -149,3 +366,216 @@ where { pyo3_async_runtimes::generic::future_into_py::(py, fut) } + +#[cfg(test)] +mod tests { + use super::*; + + // RUNTIME, GENERATION, OUTSTANDING, and ATFORK_INSTALLED are process-wide + // statics, and Rust's test harness runs tests in parallel by default, + // so separate test functions below would otherwise race each other + // through this shared state (this reproduced in CI: one test's + // in-flight task got reclaimed by a *different* test's concurrent + // `shutdown()` call, and another observed `OUTSTANDING` left non-zero + // by a still-running sibling). Every test takes this lock first so + // only one of them touches the shared runtime state at a time; a + // poisoned lock (a previous test's genuine failure) is still honored + // rather than cascading into every later test as an unrelated panic. + static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn lock_runtime_state_for_test() -> std::sync::MutexGuard<'static, ()> { + TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + // A task's own completion must never be the final drop of the shared + // `Runtime`: tasks carry only an `OutstandingGuard` (a plain counter + // token), never an `Arc`, specifically so this can't happen. + // Getting this wrong panics ("cannot drop a runtime in a context where + // blocking is not allowed") -- this reproduced unprompted, twice, in a + // single run of `test_nested_spawn_survives_concurrent_shutdown` under + // an earlier design that held the `Arc` for a task's whole lifetime. + #[test] + #[allow(unused_must_use)] // fire-and-forget spawn, same as future_into_py itself + fn test_top_level_task_survives_concurrent_shutdown_reclaim() { + use std::sync::mpsc; + let _lock = lock_runtime_state_for_test(); + + for _ in 0..50 { + let (tx, rx) = mpsc::channel::<()>(); + + LanceRuntime::spawn(async move { + for _ in 0..5 { + task::yield_now().await; + } + let _ = tx.send(()); + }); + + shutdown(Duration::from_secs(2)); + + rx.recv_timeout(Duration::from_secs(5)) + .expect("top-level task was abandoned by a concurrent shutdown reclaim"); + } + } + + #[test] + fn test_shutdown_stops_and_the_runtime_rebuilds_lazily_after() { + let _lock = lock_runtime_state_for_test(); + + // No runtime created yet in this process: shutdown must be a no-op, + // not a null-pointer dereference. + shutdown(Duration::from_secs(1)); + + // Force the runtime into existence, then shut it down. Bounded by + // the timeout, so a hang here means shutdown itself is broken, not + // that the test is slow. + assert_eq!(block_on(async { 1 + 1 }), 2); + shutdown(Duration::from_secs(5)); + + // A caller after shutdown -- e.g. a stray call racing with the + // atexit callback -- must get a fresh, working runtime rather than + // a dangling reference into the one just torn down. + assert_eq!(block_on(async { 2 + 2 }), 4); + + // Shutting down twice in a row (e.g. atexit firing more than once) + // must not panic or double-free. + shutdown(Duration::from_secs(5)); + shutdown(Duration::from_secs(5)); + } + + // Adapted from the concurrent reproducer that found the original bug: + // many threads hammering the runtime while shutdown races them, not + // just the sequential rebuild path above. Before every reader held its + // own `Arc`, this could dereference a runtime `shutdown` had already + // freed, or hang forever. Repeated, since a race is not guaranteed to + // show up on any single attempt. + #[test] + fn test_shutdown_is_safe_concurrently_with_live_callers() { + use std::sync::Barrier; + use std::sync::atomic::AtomicBool as StopFlag; + let _lock = lock_runtime_state_for_test(); + + for _ in 0..50 { + let barrier = Arc::new(Barrier::new(9)); + let stop = Arc::new(StopFlag::new(false)); + let workers: Vec<_> = (0..8) + .map(|_| { + let barrier = Arc::clone(&barrier); + let stop = Arc::clone(&stop); + std::thread::spawn(move || { + barrier.wait(); + while !stop.load(Ordering::Relaxed) { + assert_eq!(block_on(async { 1 + 1 }), 2); + } + }) + }) + .collect(); + + barrier.wait(); + shutdown(Duration::from_millis(50)); + stop.store(true, Ordering::Relaxed); + for worker in workers { + worker.join().unwrap(); + } + } + } + + // Reproduces the actual bug mechanism, not just concurrent `block_on` + // traffic: `future_into_py` spawns an outer task that, once running, + // spawns a second (inner) one for the real work and awaits its + // `JoinHandle` (see `pyo3_async_runtimes::generic::future_into_py_with_locals`). + // Before `spawn`/`spawn_blocking` pinned nested calls to whatever + // runtime is already executing them, a reclaim landing between the + // outer and inner spawn could bind them to two different runtime + // instances -- and if the outer task's own runtime was the one torn + // down while it awaited the inner task, it never resumed. Every wait + // here is bounded so a reintroduced bug fails this test instead of + // hanging the suite. + #[test] + #[allow(unused_must_use)] // fire-and-forget outer spawn, same as future_into_py itself + fn test_nested_spawn_survives_concurrent_shutdown() { + use std::sync::mpsc; + let _lock = lock_runtime_state_for_test(); + + for _ in 0..50 { + let stop = Arc::new(AtomicBool::new(false)); + let (done_tx, done_rx) = mpsc::channel::<()>(); + + let workers: Vec<_> = (0..8) + .map(|_| { + let stop = Arc::clone(&stop); + let done_tx = done_tx.clone(); + std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let (tx, rx) = mpsc::sync_channel::<()>(1); + LanceRuntime::spawn(async move { + let inner = LanceRuntime::spawn(async { + let _ = 1 + 1; + }); + let _ = inner.await; + let _ = tx.send(()); + }); + // A hang here across a concurrent shutdown is + // exactly the bug this test exists to catch. + let _ = rx.recv_timeout(Duration::from_secs(2)); + } + let _ = done_tx.send(()); + }) + }) + .collect(); + + std::thread::sleep(Duration::from_millis(10)); + shutdown(Duration::from_millis(50)); + stop.store(true, Ordering::Relaxed); + + for _ in 0..8 { + done_rx + .recv_timeout(Duration::from_secs(10)) + .expect("worker hung after shutdown raced a nested spawn"); + } + for worker in workers { + worker.join().unwrap(); + } + } + } + + // Forces the exact interleaving the install-race finding described: an + // installer registers as outstanding (as `spawn`'s outermost branch + // does, before it ever calls `get_runtime()`) and then pauses, while a + // concurrent `shutdown()` must not decide "nothing here" and return + // before that install actually completes and is retired in turn. + #[test] + fn test_shutdown_waits_for_a_racing_install() { + use std::sync::mpsc; + let _lock = lock_runtime_state_for_test(); + + // Clean slate: no runtime installed, OUTSTANDING at zero. + shutdown(Duration::from_secs(5)); + + let (installer_ready_tx, installer_ready_rx) = mpsc::channel::<()>(); + let (proceed_tx, proceed_rx) = mpsc::channel::<()>(); + + let installer = std::thread::spawn(move || { + let _guard = OutstandingGuard::new(); + installer_ready_tx.send(()).unwrap(); + proceed_rx.recv().unwrap(); + assert_eq!(get_runtime().block_on(async { 1 + 1 }), 2); + }); + + installer_ready_rx.recv().unwrap(); + let shutdown_thread = std::thread::spawn(|| shutdown(Duration::from_secs(5))); + // Give shutdown's polling loop several chances to (wrongly) observe + // an idle runtime before the installer is allowed to proceed. + std::thread::sleep(Duration::from_millis(50)); + proceed_tx.send(()).unwrap(); + + installer.join().unwrap(); + shutdown_thread.join().unwrap(); + + // shutdown must have waited for the install to finish and then + // retired it, not returned early and left it stranded. + assert!(RUNTIME.load_full().is_none()); + assert_eq!(OUTSTANDING.load(Ordering::SeqCst), 0); + } +} diff --git a/python/src/sql.rs b/python/src/sql.rs new file mode 100644 index 000000000..612521207 --- /dev/null +++ b/python/src/sql.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use pyo3::{Bound, PyAny, PyRef, PyResult, pyclass, pymethods}; +use uuid::Uuid; + +use crate::arrow::RecordBatchStream; +use crate::error::PythonErrorExt; +use crate::runtime::future_into_py; + +#[pyclass(name = "SqlQuery")] +pub struct Query { + inner: Arc, +} + +impl Query { + pub(crate) fn new(inner: lancedb::sql::Query) -> Self { + Self { + inner: Arc::new(inner), + } + } +} + +#[pymethods] +impl Query { + #[getter] + pub fn id(&self) -> Uuid { + self.inner.id() + } + + pub fn describe(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner.clone(); + future_into_py(self_.py(), async move { + inner + .describe() + .await + .map(QueryDescription::from) + .infer_error() + }) + } + + pub fn reader(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner.clone(); + future_into_py(self_.py(), async move { + let stream = inner.reader().await.infer_error()?; + Ok(RecordBatchStream::new(stream)) + }) + } + + pub fn cancel(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner.clone(); + future_into_py(self_.py(), async move { + inner.cancel().await.infer_error()?; + Ok(()) + }) + } +} + +#[pyclass(get_all, skip_from_py_object)] +#[derive(Clone)] +pub struct QueryDescription { + id: Uuid, + status: String, + progress: Option, + expires_at: Option>, +} + +#[pymethods] +impl QueryDescription { + fn __repr__(&self) -> String { + format!( + "QueryDescription(id={:?}, status={:?}, progress={:?}, expires_at={:?})", + self.id, self.status, self.progress, self.expires_at + ) + } +} + +impl From for QueryDescription { + fn from(description: lancedb::sql::QueryDescription) -> Self { + Self { + id: description.id, + status: description.status.to_string(), + progress: description.progress, + expires_at: description.expires_at, + } + } +} diff --git a/python/src/table.rs b/python/src/table.rs index 04343e890..c749a03ca 100644 --- a/python/src/table.rs +++ b/python/src/table.rs @@ -575,6 +575,17 @@ pub struct RefreshMaterializedViewResult { #[pymethods] impl RefreshMaterializedViewResult { + #[staticmethod] + pub fn from_json(value: &str) -> PyResult { + let result: lancedb::RefreshMaterializedViewResult = + serde_json::from_str(value).map_err(|err| { + PyValueError::new_err(format!( + "failed to decode materialized-view refresh result: {err}" + )) + })?; + Ok(Self::from(result)) + } + pub fn __repr__(&self) -> String { format!( "RefreshMaterializedViewResult(mode={}, rows_written={}, source_version={}, version={})", @@ -1772,6 +1783,40 @@ impl Table { }) } + #[pyo3(signature = (full=false, source_version=None))] + pub fn refresh_materialized_view_async( + self_: PyRef<'_, Self>, + full: bool, + source_version: Option, + ) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + let view = lancedb::MaterializedView::from_table(inner) + .await + .infer_error()?; + let mut builder = view.refresh().full(full); + if let Some(version) = source_version { + builder = builder.source_version(version); + } + let job = builder.execute_async().await.infer_error()?; + Ok(crate::job::Job::new_typed(job)) + }) + } + + pub fn materialized_view_definition(self_: PyRef<'_, Self>) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + future_into_py(self_.py(), async move { + let view = lancedb::MaterializedView::from_table(inner) + .await + .infer_error()?; + serde_json::to_string(view.definition()).map_err(|err| { + PyRuntimeError::new_err(format!( + "failed to serialize materialized-view definition: {err}" + )) + }) + }) + } + pub fn add_columns_with_schema( self_: PyRef<'_, Self>, schema: PyArrowType, diff --git a/python/tests/test_oauth.py b/python/tests/test_oauth.py index 89f5b3f8d..c2aaa6c9f 100644 --- a/python/tests/test_oauth.py +++ b/python/tests/test_oauth.py @@ -1,10 +1,19 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The LanceDB Authors +import asyncio import importlib.util +import json +import os +import subprocess import sys +import threading +import urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path +import pytest + def _load_oauth_module(): oauth_path = ( @@ -31,3 +40,341 @@ def test_oauth_config_repr_redacts_client_secret(): rendered = repr(config) assert "super-secret" not in rendered assert "client_secret" not in rendered + + +def test_authorization_code_uses_pkce_by_default(): + oauth = _load_oauth_module() + + config = oauth.OAuthConfig( + issuer_url="https://issuer.example.com", + client_id="client-id", + scopes=["openid"], + flow=oauth.OAuthFlowType.AUTHORIZATION_CODE, + ) + + assert config.use_pkce is True + assert config.redirect_uri is None + assert config.callback_port is None + + +def test_device_code_flow_value(): + oauth = _load_oauth_module() + + assert oauth.OAuthFlowType.DEVICE_CODE.value == "device_code" + + +def test_client_auth_method_values(): + oauth = _load_oauth_module() + + assert oauth.ClientAuthMethod.NONE.value == "none" + assert oauth.ClientAuthMethod.CLIENT_SECRET_BASIC.value == "client_secret_basic" + assert oauth.ClientAuthMethod.CLIENT_SECRET_POST.value == "client_secret_post" + + +def test_client_auth_method_defaults_to_none(): + oauth = _load_oauth_module() + + config = oauth.OAuthConfig( + issuer_url="https://issuer.example.com", + client_id="client-id", + scopes=["openid"], + client_auth_method=oauth.ClientAuthMethod.CLIENT_SECRET_POST, + ) + + assert config.client_auth_method is oauth.ClientAuthMethod.CLIENT_SECRET_POST + + default_config = oauth.OAuthConfig( + issuer_url="https://issuer.example.com", + client_id="client-id", + scopes=["openid"], + ) + assert default_config.client_auth_method is None + + +def test_token_cache_options_default_to_memory_only(): + oauth = _load_oauth_module() + + config = oauth.OAuthConfig( + issuer_url="https://issuer.example.com", + client_id="client-id", + scopes=["openid"], + ) + assert config.token_cache is None + assert config.resource is None + assert config.audience is None + + options = oauth.TokenCacheOptions() + assert options.cache_dir is None + assert options.lock_timeout_secs is None + + +def _remote_oauth(): + pytest.importorskip("lancedb") + from lancedb.remote import oauth as remote_oauth + + return remote_oauth + + +def _device_config(remote_oauth, issuer_url, cache_dir): + return remote_oauth.OAuthConfig( + issuer_url=issuer_url, + client_id="client-id", + scopes=["openid"], + flow=remote_oauth.OAuthFlowType.DEVICE_CODE, + token_cache=remote_oauth.TokenCacheOptions(cache_dir=str(cache_dir)), + ) + + +def test_oauth_session_status_and_logout_without_cache_entry(tmp_path): + remote_oauth = _remote_oauth() + config = _device_config(remote_oauth, "https://issuer.example.com", tmp_path) + + session = remote_oauth.OAuthSession(config) + status = asyncio.run(session.status()) + assert status.refreshable is False + assert status.issuer_url == "https://issuer.example.com" + assert status.client_id == "client-id" + assert status.scopes == ["openid"] + assert status.flow == "device_code" + assert status.obtained_at is None + + logout = asyncio.run(session.logout()) + assert logout.removed is False + + +class _MockIdpState: + def __init__(self, port): + self.port = port + self.lock = threading.Lock() + self.device_authorizations = 0 + self.refresh_grants = 0 + self.invalid_grant_rejections = 0 + self.access_tokens_issued = 0 + self.current_refresh = None + self.requests = [] + + +class _MockIdpHandler(BaseHTTPRequestHandler): + @property + def state(self) -> _MockIdpState: + return self.server.state + + def log_message(self, fmt, *args): + pass + + def _respond(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path.startswith("/.well-known/openid-configuration"): + base = f"http://127.0.0.1:{self.state.port}" + self._respond( + 200, + { + "token_endpoint": f"{base}/token", + "device_authorization_endpoint": f"{base}/device", + }, + ) + else: + self._respond(404, {}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode() + params = urllib.parse.parse_qs(body) + with self.state.lock: + self.state.requests.append(params) + + if self.path == "/device": + with self.state.lock: + self.state.device_authorizations += 1 + base = f"http://127.0.0.1:{self.state.port}" + self._respond( + 200, + { + "device_code": "device-code", + "user_code": "ABCD-EFGH", + "verification_uri": f"{base}/verify", + "expires_in": 60, + "interval": 1, + }, + ) + return + + if self.path == "/token": + grant_type = params.get("grant_type", [""])[0] + with self.state.lock: + if grant_type == "refresh_token": + self.state.refresh_grants += 1 + offered = params.get("refresh_token", [""])[0] + if offered != self.state.current_refresh: + self.state.invalid_grant_rejections += 1 + self._respond(400, {"error": "invalid_grant"}) + return + elif "device_code" not in grant_type: + self._respond(400, {"error": "unsupported_grant_type"}) + return + self.state.access_tokens_issued += 1 + number = self.state.access_tokens_issued + refresh = f"refresh-{number}" + self.state.current_refresh = refresh + self._respond( + 200, + { + "access_token": f"access-{number}", + "refresh_token": refresh, + "expires_in": 3600, + }, + ) + return + + self._respond(404, {}) + + +def _start_mock_idp() -> tuple[_MockIdpState, HTTPServer]: + server = HTTPServer(("127.0.0.1", 0), _MockIdpHandler) + state = _MockIdpState(server.server_address[1]) + server.state = state + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return state, server + + +def _run_subprocess(script: Path, issuer_url: str, cache_dir: Path, target: dict): + env = dict(os.environ) + env["LANCEDB_OAUTH_BROWSER"] = "/usr/bin/true" + result = subprocess.run( + [sys.executable, str(script), issuer_url, str(cache_dir), json.dumps(target)], + capture_output=True, + text=True, + timeout=120, + env=env, + ) + assert result.returncode == 0, ( + f"subprocess failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + return result + + +LOGIN_SCRIPT = """ +import asyncio +import json +import sys + +from lancedb.remote import OAuthConfig, OAuthFlowType, OAuthSession, TokenCacheOptions + +issuer_url, cache_dir = sys.argv[1], sys.argv[2] +config = OAuthConfig( + issuer_url=issuer_url, + client_id="client-id", + scopes=["openid"], + flow=OAuthFlowType.DEVICE_CODE, + token_cache=TokenCacheOptions(cache_dir=cache_dir), + **json.loads(sys.argv[3]), +) +session = OAuthSession(config) +status = asyncio.run(session.login()) +assert status.refreshable, "login must cache a refresh token" +assert status.resource == config.resource +assert status.audience == config.audience +print("LOGIN-OK") +""" + +REUSE_SCRIPT = """ +import asyncio +import json +import sys + +import lancedb +from lancedb.remote import OAuthConfig, OAuthFlowType, OAuthSession, TokenCacheOptions + +issuer_url, cache_dir = sys.argv[1], sys.argv[2] +config = OAuthConfig( + issuer_url=issuer_url, + client_id="client-id", + scopes=["openid"], + flow=OAuthFlowType.DEVICE_CODE, + token_cache=TokenCacheOptions(cache_dir=cache_dir), + **json.loads(sys.argv[3]), +) + +session = OAuthSession(config) +status = asyncio.run(session.status()) +assert status.refreshable, "second process must see the cached session" + + +async def main(): + # Point the database endpoint at a dead port. OAuth headers are fetched + # before the request is sent, so a successful refresh proves the second + # process reused the cached session; only the database call fails. + db = await lancedb.connect_async( + "db://e2e", + host_override="http://127.0.0.1:1", + client_config={"retry_config": {"retries": 0}}, + oauth_config=config, + ) + try: + await db.table_names() + except Exception: + print("DATABASE-UNREACHABLE-AS-EXPECTED") + else: + raise AssertionError("expected the database request to fail") + + +asyncio.run(main()) +print("REUSE-OK") +""" + + +@pytest.mark.parametrize( + "target", + [ + {}, + { + "resource": "https://api.example.com/a?x=1&y=two", + "audience": "audience + & / ü", + }, + ], +) +def test_cross_process_session_reuse_without_new_prompt(tmp_path, target): + pytest.importorskip("lancedb") + state, server = _start_mock_idp() + try: + issuer_url = f"http://127.0.0.1:{state.port}" + login_script = tmp_path / "login.py" + login_script.write_text(LOGIN_SCRIPT) + reuse_script = tmp_path / "reuse.py" + reuse_script.write_text(REUSE_SCRIPT) + cache_dir = tmp_path / "oauth-cache" + + result = _run_subprocess(login_script, issuer_url, cache_dir, target) + assert "LOGIN-OK" in result.stdout + assert state.device_authorizations == 1 + + result = _run_subprocess(reuse_script, issuer_url, cache_dir, target) + assert "REUSE-OK" in result.stdout + assert "DATABASE-UNREACHABLE-AS-EXPECTED" in result.stdout + + # The second process refreshed exactly once and never started a new + # interactive device flow. + assert state.refresh_grants == 1 + assert state.device_authorizations == 1 + assert state.invalid_grant_rejections == 0 + + assert len(state.requests) == 3 + for params in state.requests: + for key in ("resource", "audience"): + assert params.get(key) == ([target[key]] if key in target else None) + config = _device_config(_remote_oauth(), issuer_url, cache_dir) + config.resource = target.get("resource") + config.audience = target.get("audience") + logout = asyncio.run(_remote_oauth().OAuthSession(config).logout()) + assert logout.removed is True + finally: + server.shutdown() + server.server_close() diff --git a/python/uv.lock b/python/uv.lock index c957a4c06..8366dd397 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2005,7 +2005,7 @@ requires-dist = [ { name = "pyarrow-stubs", marker = "extra == 'tests'", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.7.4,<3" }, { name = "pylance", marker = "extra == 'pylance'", specifier = ">=5.0.0b5" }, - { name = "pylance", marker = "extra == 'tests'", specifier = "==9.0.0rc1" }, + { name = "pylance", marker = "extra == 'tests'", specifier = "==9.0.0" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" }, { name = "pytest", marker = "extra == 'tests'", specifier = ">=7.0" }, { name = "pytest-asyncio", marker = "extra == 'tests'", specifier = ">=0.21" }, @@ -3912,7 +3912,7 @@ crypto = [ [[package]] name = "pylance" -version = "7.0.0" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace" }, @@ -3922,12 +3922,12 @@ dependencies = [ { name = "pyarrow" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/ad/2f64921bf346e7075aef24a72595db44821724a3d89a9a92dd24e79632aa/pylance-7.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:98422021975be76e72b1572f41b8c9abb3bee5bdc9bfa5e9ce731110a65ed4d1", size = 62134146, upload-time = "2026-05-27T21:59:37.459Z" }, - { url = "https://files.pythonhosted.org/packages/73/1c/c5a01bee0160b55d9a98895cbd33091d038f0a0995b121ab72e629008d02/pylance-7.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bec86ee5b6fbd8bfc493e653f0a1fba0303cfe5492b9b46fc25ab908edc7183", size = 65373684, upload-time = "2026-05-27T22:04:01.584Z" }, - { url = "https://files.pythonhosted.org/packages/eb/da/1fe8b8f7dbfe734d76af76acc994fc360a0d0c79a4874ef69f5a72a58fe3/pylance-7.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881491432c53184e52f8d1db8d5f872f39a03f36fb104bec77b33d379519d8b5", size = 69458555, upload-time = "2026-05-27T22:16:50.567Z" }, - { url = "https://files.pythonhosted.org/packages/76/f0/dd505cf3fd0226ab9d94759acd713125af1d3bfacfd80bbd52e3b9f89509/pylance-7.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:18453999e7fff4f76b16d6b7882c9df0628bd142ff95e2461bd7dd5ee3fe0af3", size = 65394430, upload-time = "2026-05-27T22:05:30.923Z" }, - { url = "https://files.pythonhosted.org/packages/17/ba/2357b81034f28eb00790e258ed140289a6a887a7468ca9df6349fd186b27/pylance-7.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:04a58051d408c60fe76d41a220dcaf8fea8fb6d1aa0ca78a709b60bc3cc8d19a", size = 69473470, upload-time = "2026-05-27T22:17:18.935Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ec/5c00b6303a67d787f9475141832cbdc513d674ac3dcaeef8a7b169905e65/pylance-7.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:467d4864af047eaab4e1370e2f1e88e2c6f507c079874421116cb41d78bc3629", size = 74792863, upload-time = "2026-05-27T22:19:23.875Z" }, + { url = "https://files.pythonhosted.org/packages/e9/be/45733acd64801991852aac8e658601fd8fc12f76ceb81e57fca690896b90/pylance-9.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8257213501d3298c5b6a344d60938e4bbe4de9f00cd3265371a56d1dc3dd15ca", size = 68377982, upload-time = "2026-07-24T16:53:45.247Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3e/1ef707cb215cc7268c63ad84a91344ad6313d3984b343eeb19a9b708698e/pylance-9.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804eedfa1fda2e703cca8580c76f0b44a1b849b725a8908c06f8acfca811732f", size = 71844362, upload-time = "2026-07-24T16:56:07.6Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fb/a499e5c53ddb75c7de44100fd2bb1f7cc735966200d20292eed7af9ef552/pylance-9.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0b75595e3766c1d5f4c90abdc52337b4de60f1a52d123a4f8c4e5bcbbbfa8f", size = 75663088, upload-time = "2026-07-24T17:10:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/e9/80/0714e09f64a68dbdf62558955e737a7436df763b9834a6aba506861b5352/pylance-9.0.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d2f69c5c390ae3710c35a429905fe15f769951777fafb1b72a359e035ec121f7", size = 71866858, upload-time = "2026-07-24T16:56:35.937Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3c/78d3a6d6ca0d843b7c3ac0c30d9cd2cf4635b0c2cabe6ec66583d5bfc1a1/pylance-9.0.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:836268a7832d62f3d5ccbe1c3fca239621971d90297bcfff14a70b3cb6842aa8", size = 75642656, upload-time = "2026-07-24T17:13:11.879Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c1/dc9c9a31e171530ec0add024d922488c046437d32a7087c29e254a7eacc7/pylance-9.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:96441d27a5ed3805300388ccf8f31835cd280c98751f80b6a1a11dcd6808fc43", size = 81668288, upload-time = "2026-07-24T17:05:38.707Z" }, ] [[package]] diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index 881a5017e..bde33283d 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lancedb" -version = "0.38.0-beta.12" +version = "0.40.0-beta.1" edition.workspace = true description = "LanceDB: A serverless, low-latency vector database for AI applications" license.workspace = true @@ -21,6 +21,8 @@ arrow-select = { workspace = true } arrow-ord = { workspace = true } arrow-cast = { workspace = true } arrow-ipc.workspace = true +arrow-flight = { workspace = true, optional = true } +prost = { version = "0.14", optional = true } chrono = { workspace = true } datafusion-catalog.workspace = true datafusion-common.workspace = true @@ -51,7 +53,7 @@ metrics = { workspace = true, optional = true } metrics-util = { workspace = true, optional = true } moka = { workspace = true } pin-project = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["io-util", "net", "time"] } log.workspace = true async-trait = { workspace = true } bytes = { workspace = true } @@ -65,6 +67,7 @@ serde_json = { workspace = true } async-openai = { version = "0.20.0", optional = true } serde_with = { version = "3.8.1" } tempfile = { workspace = true } +aws-smithy-types = { workspace = true, optional = true } aws-sdk-bedrockruntime = { version = "1.27.0", optional = true } # For remote feature reqwest = { version = "0.12.0", default-features = false, features = [ @@ -77,8 +80,13 @@ reqwest = { version = "0.12.0", default-features = false, features = [ "rustls-tls-native-roots", "stream", ], optional = true } +tonic = { workspace = true, optional = true } http = { version = "1", optional = true } # Matching what is in reqwest +oauth2 = { workspace = true, optional = true } urlencoding = { version = "2", optional = true } +base64 = { version = "0.22", optional = true } +fs4 = { version = "0.13", optional = true } +webbrowser = { version = "1", optional = true } uuid = { workspace = true, features = ["v5"] } polars-arrow = { version = ">=0.37,<0.40.0", optional = true } polars = { version = ">=0.37,<0.40.0", optional = true } @@ -92,13 +100,14 @@ candle-transformers = { version = "0.9.1", optional = true } candle-nn = { version = "0.9.1", optional = true } tokenizers = { version = "0.19.1", optional = true } semver = { workspace = true } +roaring = "0.11.4" +sha2 = "0.10" [dev-dependencies] anyhow = "1" lance-testing = { workspace = true } tempfile = { workspace = true } random_word = { version = "0.4.3", features = ["en"] } -roaring = "0.11.4" tokio = { workspace = true, features = ["io-util", "macros", "net", "test-util"] } uuid = { workspace = true } walkdir = "2" @@ -107,6 +116,7 @@ aws-sdk-s3 = { version = "1.55.0" } aws-sdk-kms = { version = "1.48.0" } aws-config = { version = "1.5.10" } aws-smithy-runtime = { version = "1.9.1" } +aws-smithy-types.workspace = true datafusion.workspace = true http-body = "1" # Matching reqwest rstest = "0.23.0" @@ -118,7 +128,10 @@ pprof = { version = "0.14", features = ["flamegraph"] } [features] default = [] +# Native spatial indexes and queries. Remote RTree requests do not require this feature. +geo = ["lance/geo"] aws = [ + "dep:aws-smithy-types", "lance/aws", "lance-io/aws", "lance-namespace-impls/dir-aws", @@ -145,9 +158,16 @@ huggingface = [ ] dynamodb = ["lance/dynamodb", "aws"] remote = [ + "dep:arrow-flight", + "dep:prost", "dep:reqwest", "dep:http", + "dep:oauth2", + "dep:tonic", "dep:urlencoding", + "dep:base64", + "dep:webbrowser", + "dep:fs4", "lance-namespace-impls/rest", "lance-namespace-impls/rest-adapter", ] @@ -162,7 +182,7 @@ metrics = ["dep:metrics", "lance/metrics", "lance-io/metrics"] metrics-otel = ["metrics", "dep:metrics-util"] fp16kernels = ["lance-linalg/fp16kernels"] s3-test = [] -bedrock = ["dep:aws-sdk-bedrockruntime"] +bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"] openai = ["dep:async-openai", "dep:reqwest"] polars = ["dep:polars-arrow", "dep:polars"] sentence-transformers = [ diff --git a/rust/lancedb/src/arrow.rs b/rust/lancedb/src/arrow.rs index c40459b40..8cb7dbda7 100644 --- a/rust/lancedb/src/arrow.rs +++ b/rust/lancedb/src/arrow.rs @@ -163,7 +163,7 @@ pub struct PolarsDataFrameRecordBatchReader { impl PolarsDataFrameRecordBatchReader { /// Creates a new `PolarsDataFrameRecordBatchReader` from a given Polars DataFrame. /// If the input dataframe does not have aligned chunks, this function undergoes - /// the costly operation of reallocating each series as a single contigous chunk. + /// the costly operation of reallocating each series as a single contiguous chunk. pub fn new(mut df: DataFrame) -> Result { df.align_chunks(); let arrow_schema = diff --git a/rust/lancedb/src/blob.rs b/rust/lancedb/src/blob.rs index d59123ec3..cbc0724c6 100644 --- a/rust/lancedb/src/blob.rs +++ b/rust/lancedb/src/blob.rs @@ -7,7 +7,9 @@ //! raw `Binary` / `LargeBinary` into the blob struct layout. Queries return //! small descriptors, not bytes. //! -//! Blob tables require Lance file format >= 2.2 and stable row ids at create. +//! Blob tables require Lance file format >= 2.2. `_rowid` values stay valid +//! after compaction when the table has stable row ids. Overwrite is a new +//! create and does not keep the previous table's stable row id setting. use std::ops::Range; use std::sync::Arc; @@ -324,6 +326,7 @@ pub(crate) fn blob_column_names(schema: &Schema) -> Vec { } /// Bumps storage format to at least [`LanceFileVersion::V2_2`] for blob schemas. +/// Leaves `enable_stable_row_ids` unchanged. pub(crate) fn ensure_blob_storage_version(schema: &Schema, params: &mut WriteParams) { if !has_blob_columns(schema) { return; @@ -385,6 +388,30 @@ fn ensure_all_row_ids_resolved(column: &str, requested: usize, resolved: usize) } } +/// Lance take reports a missing physical row address as NotSupported or InvalidInput. +fn map_blob_take_error(column: &str, requested: usize, err: lance::Error) -> Error { + let missing_row_addr = match &err { + lance::Error::NotSupported { source, .. } => { + source.to_string().contains("must not target deleted rows") + } + lance::Error::InvalidInput { source, .. } => source + .to_string() + .contains("belongs to non-existent fragment"), + _ => false, + }; + + if missing_row_addr { + Error::InvalidInput { + message: format!( + "blob read for column '{column}' requested {requested} row ids but some \ + do not exist in the table; pass row ids collected from this table" + ), + } + } else { + err.into() + } +} + /// Materialize blob-local ranges (same length and order as `requests`, nulls preserved). pub(crate) async fn take_blob_ranges_aligned( dataset: &Arc, @@ -405,7 +432,8 @@ pub(crate) async fn take_blob_ranges_aligned( .with_row_ids(lance_requests) .preserve_order(true) .execute() - .await?; + .await + .map_err(|err| map_blob_take_error(column, requests.len(), err))?; ensure_all_row_ids_resolved(column, requests.len(), payloads.len())?; let mut builder = LargeBinaryBuilder::new(); @@ -434,7 +462,8 @@ pub(crate) async fn take_blobs_aligned( .with_row_ids(row_ids.to_vec()) .preserve_order(true) .execute() - .await?; + .await + .map_err(|err| map_blob_take_error(column, row_ids.len(), err))?; ensure_all_row_ids_resolved(column, row_ids.len(), payloads.len())?; let mut builder = LargeBinaryBuilder::new(); @@ -458,7 +487,10 @@ pub(crate) async fn take_blob_files_aligned( return Ok(Vec::new()); } - let handles = dataset.take_blobs(row_ids, column).await?; + let handles = dataset + .take_blobs(row_ids, column) + .await + .map_err(|err| map_blob_take_error(column, row_ids.len(), err))?; ensure_all_row_ids_resolved(column, row_ids.len(), handles.len())?; Ok(handles .into_iter() @@ -500,10 +532,27 @@ mod tests { fn storage_version_bumps_to_v2_2() { let mut params = WriteParams::default(); ensure_blob_storage_version(&blob_schema(), &mut params); - assert_eq!( - params.data_storage_version.unwrap().resolve(), - ConcreteFileVersion::V2_2 - ); + let resolved = params + .data_storage_version + .unwrap_or(LanceFileVersion::Stable) + .resolve(); + assert_eq!(resolved, ConcreteFileVersion::V2_2); + assert!(!params.enable_stable_row_ids); + } + + #[test] + fn storage_version_leaves_stable_row_ids_enabled() { + let mut params = WriteParams { + enable_stable_row_ids: true, + ..Default::default() + }; + ensure_blob_storage_version(&blob_schema(), &mut params); + assert!(params.enable_stable_row_ids); + let resolved = params + .data_storage_version + .unwrap_or(LanceFileVersion::Stable) + .resolve(); + assert_eq!(resolved, ConcreteFileVersion::V2_2); } #[test] @@ -576,5 +625,6 @@ mod tests { let mut params = WriteParams::default(); ensure_blob_storage_version(&schema, &mut params); assert!(params.data_storage_version.is_none()); + assert!(!params.enable_stable_row_ids); } } diff --git a/rust/lancedb/src/catalog.rs b/rust/lancedb/src/catalog.rs new file mode 100644 index 000000000..5eaa1d49e --- /dev/null +++ b/rust/lancedb/src/catalog.rs @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Catalogs manage databases. A remote catalog is the root namespace of a server. +//! +//! ``` +//! # #[cfg(feature = "remote")] +//! # async fn example() -> lancedb::Result<()> { +//! let catalog = lancedb::connect_catalog("https://my-server.example") +//! .api_key("my-api-key") +//! .execute().await?; +//! let database = catalog.create_database("analytics").await?; +//! # Ok(()) +//! # } +//! ``` + +use std::fmt; +use std::sync::Arc; + +use crate::Result; +use crate::connection::Connection; +use crate::database::Database; +use crate::embeddings::{EmbeddingRegistry, MemoryRegistry}; + +/// Options for creating a database. By default an existing name is an error. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct CreateDatabaseRequest { + /// Logical database name, including any literal slashes. + pub name: String, + /// Open the existing database if it is already registered. + pub exist_ok: bool, +} + +impl CreateDatabaseRequest { + /// Initialize a request with the default behavior. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + exist_ok: false, + } + } + + /// Open an existing database instead of failing if its name already exists. + pub fn exist_ok(mut self, value: bool) -> Self { + self.exist_ok = value; + self + } +} + +impl> From for CreateDatabaseRequest { + fn from(name: T) -> Self { + Self::new(name) + } +} + +/// Options for restricted database deletion. Tables must be removed first. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct DropDatabaseRequest { + /// Logical database name, including any literal slashes. + pub name: String, + /// Succeed if the database is absent. + pub ignore_missing: bool, +} + +impl DropDatabaseRequest { + /// Initialize a request with the default behavior. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ignore_missing: false, + } + } + + /// Succeed when the database does not exist. + pub fn ignore_missing(mut self, value: bool) -> Self { + self.ignore_missing = value; + self + } +} + +impl> From for DropDatabaseRequest { + fn from(name: T) -> Self { + Self::new(name) + } +} + +/// Pagination options for listing databases. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct ListDatabasesRequest { + /// Maximum number of names to return. None uses the server default. + pub limit: Option, + /// Opaque continuation token from a previous response. None starts a listing. + pub page_token: Option, +} + +impl ListDatabasesRequest { + /// Set the maximum page size (1 through 2147483647). + pub fn limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } + /// Resume a listing from an opaque continuation token. + pub fn page_token(mut self, token: impl Into) -> Self { + self.page_token = Some(token.into()); + self + } +} + +/// One page of database names, relative to the catalog. +#[derive(Clone, Debug, Default)] +pub struct ListDatabasesResponse { + /// Logical database names on this page. + pub databases: Vec, + /// None indicates the end of the listing. + pub page_token: Option, +} + +/// A backend that manages databases. Implementations own database lifecycle semantics. +#[async_trait::async_trait] +pub trait Catalog: Send + Sync + std::fmt::Debug + 'static { + /// Catalog endpoint or location. + fn uri(&self) -> &str; + /// Create a database, or open it when `exist_ok` permits. + async fn create_database(&self, request: CreateDatabaseRequest) -> Result>; + /// Drop an empty database. This must not cascade to tables. + async fn drop_database(&self, request: DropDatabaseRequest) -> Result<()>; + /// List a page of database names. + async fn list_databases(&self, request: ListDatabasesRequest) -> Result; + /// Connect to an existing database by its logical name. + async fn connect_database(&self, name: &str) -> Result>; +} + +/// A catalog connection that returns ordinary LanceDB database connections. +#[derive(Clone)] +pub struct CatalogConnection { + catalog: Arc, + embedding_registry: Arc, +} + +impl fmt::Debug for CatalogConnection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CatalogConnection") + .field("uri", &self.uri()) + .finish_non_exhaustive() + } +} + +impl CatalogConnection { + /// Wrap a catalog implementation using the default in-memory embedding registry. + pub fn new(catalog: Arc) -> Self { + Self { + catalog, + embedding_registry: Arc::new(MemoryRegistry::new()), + } + } + + /// Provide the registry used by databases opened through this connection. + pub fn with_embedding_registry(mut self, registry: Arc) -> Self { + self.embedding_registry = registry; + self + } + + /// The catalog endpoint or location. + pub fn uri(&self) -> &str { + self.catalog.uri() + } + /// Access the underlying backend. + pub fn catalog(&self) -> &Arc { + &self.catalog + } + + /// Create a database; pass a name or [`CreateDatabaseRequest`] for additional options. + pub async fn create_database( + &self, + request: impl Into, + ) -> Result { + Ok(Connection::new( + self.catalog.create_database(request.into()).await?, + self.embedding_registry.clone(), + )) + } + + /// Drop an empty database; pass a name or [`DropDatabaseRequest`] for additional options. + pub async fn drop_database(&self, request: impl Into) -> Result<()> { + self.catalog.drop_database(request.into()).await + } + + /// List a page of databases. Pass [`ListDatabasesRequest::default`] for the first page. + pub async fn list_databases( + &self, + request: ListDatabasesRequest, + ) -> Result { + self.catalog.list_databases(request).await + } + + /// Connect to a database without creating it if it is missing. + pub async fn connect_database(&self, name: impl AsRef) -> Result { + Ok(Connection::new( + self.catalog.connect_database(name.as_ref()).await?, + self.embedding_registry.clone(), + )) + } +} + +/// Configure a connection to a remote catalog. +#[cfg(feature = "remote")] +#[derive(Debug)] +pub struct ConnectCatalogBuilder { + endpoint: String, + options: crate::remote::RemoteCatalogOptions, +} + +#[cfg(feature = "remote")] +impl ConnectCatalogBuilder { + /// Start configuring a remote HTTP(S) catalog connection. + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + options: Default::default(), + } + } + /// Authenticate with an API key. + pub fn api_key(mut self, key: impl Into) -> Self { + self.options.api_key = Some(key.into()); + self + } + /// Configure headers, TLS, timeouts, and other shared client settings. + pub fn client_config(mut self, config: crate::remote::ClientConfig) -> Self { + self.options.client_config = config; + self + } + /// Set the SQL service endpoint inherited by database connections. + /// + /// Required to execute SQL when the catalog endpoint uses HTTPS. The SQL + /// connection is initialized lazily, using the ordinary remote SQL client. + pub fn sql_host_override(mut self, endpoint: impl Into) -> Self { + self.options.sql_host_override = Some(endpoint.into()); + self + } + /// Configure table read consistency for opened databases. + pub fn read_consistency_interval(mut self, interval: std::time::Duration) -> Self { + self.options.read_consistency_interval = Some(interval); + self + } + /// Authenticate using OAuth; mutually exclusive with API keys and header providers. + pub fn oauth_config(mut self, config: crate::remote::OAuthConfig) -> Self { + self.options.oauth_config = Some(config); + self + } + /// Connect to the server's root namespace. Database-scoped headers are omitted. + pub async fn execute(self) -> Result { + Ok(CatalogConnection::new(Arc::new( + crate::remote::RemoteCatalog::try_new(&self.endpoint, self.options)?, + ))) + } +} diff --git a/rust/lancedb/src/connection.rs b/rust/lancedb/src/connection.rs index 5f66d9dee..7234a911f 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -23,16 +23,21 @@ use crate::connection::create_table::CreateTableBuilder; use crate::data::scannable::Scannable; use crate::database::listing::ListingDatabase; use crate::database::{ - CloneTableRequest, Database, DatabaseOptions, JobDescription, JobInfo, OpenTableRequest, - ReadConsistency, TableNamesRequest, + CloneTableRequest, Database, DatabaseOptions, JobInfo, OpenTableRequest, ReadConsistency, + TableNamesRequest, }; use crate::embeddings::{EmbeddingRegistry, MemoryRegistry}; use crate::error::{Error, Result}; #[cfg(feature = "remote")] use crate::remote::{ client::ClientConfig, - db::{OPT_REMOTE_API_KEY, OPT_REMOTE_HOST_OVERRIDE, OPT_REMOTE_REGION}, + db::{ + OPT_REMOTE_API_KEY, OPT_REMOTE_HOST_OVERRIDE, OPT_REMOTE_REGION, + OPT_REMOTE_SQL_HOST_OVERRIDE, + }, }; +use crate::secrets::SecretInfo; +use crate::utils::{validate_secret_component, validate_secret_reference}; use lance::io::ObjectStoreParams; pub use lance_file::version::LanceFileVersion; #[cfg(feature = "remote")] @@ -322,6 +327,43 @@ pub struct CloneTableBuilder { request: CloneTableRequest, } +/// Builder for asynchronously executing a SQL statement on a remote database. +pub struct ExecuteQueryAsyncBuilder { + parent: Arc, + query: String, + default_namespace_path: Vec, +} + +impl ExecuteQueryAsyncBuilder { + fn new(parent: Arc, query: String) -> Self { + Self { + parent, + query, + default_namespace_path: vec!["public".to_string()], + } + } + + /// Set the namespace used for unqualified table names. + /// + /// An empty path is treated as `public`, which is the SQL name for the + /// root Lance namespace. + pub fn default_namespace_path(mut self, path: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.default_namespace_path = path.into_iter().map(Into::into).collect(); + self + } + + /// Start the statement and return its asynchronous query handle. + pub async fn execute(self) -> Result { + self.parent + .execute_query_async(&self.query, &self.default_namespace_path) + .await + } +} + impl CloneTableBuilder { fn new(parent: Arc, target_table_name: String, source_uri: String) -> Self { Self { @@ -405,6 +447,51 @@ impl Connection { &self.internal } + /// Start executing SQL on a remote LanceDB database. + /// + /// The query can reference tables in other databases with SQL dot notation. + /// Use [`ExecuteQueryAsyncBuilder::default_namespace_path`] to avoid qualifying + /// tables in the default namespace. Local connections return + /// [`Error::NotSupported`]. + /// + /// # Example + /// + /// ```no_run + /// # async fn query(db: &lancedb::Connection) -> lancedb::Result<()> { + /// use futures::TryStreamExt; + /// + /// let query = db + /// .execute_query_async("SELECT * FROM events LIMIT 10") + /// .default_namespace_path(["public"]) + /// .execute() + /// .await?; + /// println!("query id: {}", query.id()); + /// let mut batches = query.reader().await?; + /// while let Some(batch) = batches.try_next().await? { + /// println!("received {} rows", batch.num_rows()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn execute_query_async(&self, query: impl Into) -> ExecuteQueryAsyncBuilder { + ExecuteQueryAsyncBuilder::new(self.internal.clone(), query.into()) + } + + /// Describe a submitted SQL query by its connection-scoped id. + /// + /// This performs one bounded status poll using state retained by this + /// connection. Running state with a live query handle is not evicted; + /// abandoned state has bounded retention, and server expiration is + /// honored. Terminal state is retained briefly. + /// Query ids are not portable to another connection. Local connections + /// return [`Error::NotSupported`]. + pub async fn describe_query( + &self, + query_id: uuid::Uuid, + ) -> Result { + self.internal.describe_query(query_id).await + } + /// Get the names of all tables in the database /// /// The names will be returned in lexicographical order (ascending) @@ -496,11 +583,13 @@ impl Connection { ) } - /// Register a Python callable as a new immutable Function version. + /// Build and register a Python callable as an immutable Function version. /// - /// Registration is remote-only and always asynchronous. Waiting on the - /// returned typed job yields the durable [`crate::function::FunctionVersion`]. + /// The server-side job builds the OCI image, then registers the completed + /// artifact. Waiting on the returned typed job yields the durable + /// [`crate::function::FunctionVersion`]. Creation is remote-only. /// Local databases return [`Error::NotSupported`]. + /// pub async fn create_function_async( &self, request: crate::function::FunctionRegistrationRequest, @@ -523,6 +612,125 @@ impl Connection { .await } + /// List every published immutable Function version in the remote catalog. + /// + /// Results are ordered by Function name then version. The client walks all + /// server pages before returning. Local databases return + /// [`Error::NotSupported`]. + /// + /// # Example + /// + /// ```no_run + /// # async fn list_functions( + /// # connection: &lancedb::Connection, + /// # ) -> Result<(), Box> { + /// for function in connection.list_functions().await? { + /// println!("{} {}", function.name(), function.version()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn list_functions(&self) -> Result> { + self.internal.list_functions().await + } + + /// Remove the current Function name binding, retaining the object history. + /// + /// Returns `true` when the server appended a Dropped transition and + /// `false` for an idempotent replay. Local databases return + /// [`Error::NotSupported`]. + pub async fn drop_function( + &self, + name: impl AsRef, + version: impl AsRef, + ) -> Result { + self.internal + .drop_function(name.as_ref(), version.as_ref()) + .await + } + + /// Create a named Secret in this database. + /// + /// Fails if the name is taken, so a create can never silently become a + /// rotation. There is no API that reads a stored credential back; the only + /// consumer is a Function that binds the Secret by name. Local databases + /// return [`Error::NotSupported`]. + pub async fn create_secret( + &self, + name: impl AsRef, + value: impl AsRef, + namespace_path: &[String], + ) -> Result<()> { + validate_secret_reference(name.as_ref(), namespace_path)?; + self.internal + .create_secret(name.as_ref(), value.as_ref(), namespace_path) + .await + } + + /// Replace the credential behind an existing Secret. + /// + /// Fails if it does not exist. Every Function bound to the Secret resolves + /// the new value from its next execution, and no new Function version is + /// minted -- which is what lets a rotation reach columns pinned to a + /// version registered before it. Local databases return + /// [`Error::NotSupported`]. + pub async fn alter_secret( + &self, + name: impl AsRef, + value: impl AsRef, + namespace_path: &[String], + ) -> Result<()> { + validate_secret_reference(name.as_ref(), namespace_path)?; + self.internal + .alter_secret(name.as_ref(), value.as_ref(), namespace_path) + .await + } + + /// The names of every Secret in this database. + /// + /// Names only. No path in this API returns a stored credential, by + /// construction rather than by policy. Local databases return + /// [`Error::NotSupported`]. + pub async fn list_secrets(&self, namespace_path: &[String]) -> Result> { + for segment in namespace_path { + validate_secret_component("Secret namespace path segment", segment)?; + } + self.internal.list_secrets(namespace_path).await + } + + /// Drop a Secret. + /// + /// Functions bound to it fail at their next job, naming the Secret; that + /// is the revocation path. The name becomes free to reuse, and a new + /// Secret under it is picked up by everything still bound to that name. + /// Local databases return [`Error::NotSupported`]. + pub async fn drop_secret( + &self, + name: impl AsRef, + namespace_path: &[String], + ) -> Result<()> { + validate_secret_reference(name.as_ref(), namespace_path)?; + self.internal + .drop_secret(name.as_ref(), namespace_path) + .await + } + + /// What this database records about one Secret: its name and timestamps. + /// + /// Never the value. The type it returns has no field for one, so this is a + /// property of the API rather than of what the caller chooses to read. + /// Local databases return [`Error::NotSupported`]. + pub async fn describe_secret( + &self, + name: impl AsRef, + namespace_path: &[String], + ) -> Result { + validate_secret_reference(name.as_ref(), namespace_path)?; + self.internal + .describe_secret(name.as_ref(), namespace_path) + .await + } + /// Rename a table in the database. /// /// This is only supported in LanceDB Cloud. @@ -548,14 +756,34 @@ impl Connection { self.internal.read_consistency().await } - /// A [`crate::job::Job`] handle for a server-side job by id, suitable for - /// waiting on or cancelling the job. + /// Open a server-side job by id, returning a handle with its record + /// already populated. Fails with [`crate::Error::JobNotFound`] when the + /// server has no such job, the way [`Connection::open_table`] does for a + /// missing table. /// - /// The handle is constructed without a server round trip; an unknown id - /// surfaces when the handle is used. Only server-backed databases support - /// job handles by id. - pub fn job(&self, job_id: impl AsRef) -> Result { - self.internal.job(job_id.as_ref()) + /// This is the one way in: the returned [`crate::job::Job`] answers for + /// its own state, specification, result, failure and event history, so + /// there is no separate connection-level call for any of them. + /// + /// # Example + /// + /// ```no_run + /// # use lancedb::job::JobEventsRequest; + /// # async fn open_job( + /// # connection: &lancedb::Connection, + /// # job_id: &str, + /// # ) -> Result<(), Box> { + /// let job = connection.open_job(job_id).await?; + /// println!("{:?} {:?}", job.state(), job.result()); + /// let done = job + /// .events(JobEventsRequest::default().filter("state = 'claim_complete'")) + /// .await?; + /// println!("{} completions", done.iter().map(|b| b.num_rows()).sum::()); + /// # Ok(()) + /// # } + /// ``` + pub async fn open_job(&self, job_id: impl AsRef) -> Result { + self.internal.open_job(job_id.as_ref()).await } /// List server-side jobs across the database's tables. @@ -563,24 +791,12 @@ impl Connection { self.internal.list_jobs().await } - /// Describe a single server-side job by id. `None` when the server has no - /// such job. - pub async fn get_job(&self, job_id: impl AsRef) -> Result> { - self.internal.get_job(job_id.as_ref()).await - } - /// Request cancellation of a server-side job by id. Returns true if the /// server accepted the cancellation, false if no such job exists. pub async fn cancel_job(&self, job_id: impl AsRef) -> Result { self.internal.cancel_job(job_id.as_ref()).await } - /// The lifecycle event history of a server-side job (all jobs when - /// `job_id` is `None`), as recorded Arrow batches. - pub async fn job_history(&self, job_id: Option<&str>) -> Result> { - self.internal.job_history(job_id).await - } - /// Drop a table in the database. /// /// # Arguments @@ -697,7 +913,7 @@ impl Connection { pub struct ConnectRequest { /// Database URI /// - /// ### Accpeted URI formats + /// ### Accepted URI formats /// /// - `/path/to/database` - local database on file system. /// - `s3://bucket/path/to/database` or `gs://bucket/path/to/database` - database on cloud object store @@ -827,6 +1043,19 @@ impl ConnectBuilder { self } + /// Set the SQL service host override for a remote connection. + /// + /// The SQL client is initialized lazily when the connection first executes + /// SQL and is retained for the connection's lifetime. + #[cfg(feature = "remote")] + pub fn sql_host_override(mut self, sql_host_override: &str) -> Self { + self.request.options.insert( + OPT_REMOTE_SQL_HOST_OVERRIDE.to_string(), + sql_host_override.to_string(), + ); + self + } + /// Set the database specific options /// /// See [crate::database::listing::ListingDatabaseOptions] for the options available for @@ -1016,6 +1245,7 @@ impl ConnectBuilder { let mut merged_options = self.request.options.clone(); Self::apply_env_defaults(&ENV_VARS_TO_STORAGE_OPTS, &mut merged_options); + let sql_host_override = merged_options.get(OPT_REMOTE_SQL_HOST_OVERRIDE).cloned(); let options = RemoteDatabaseOptions::parse_from_map(&merged_options)?; let region = options.region.ok_or_else(|| Error::InvalidInput { @@ -1057,11 +1287,15 @@ impl ConnectBuilder { } let storage_options = StorageOptions(options.storage_options.clone()); + let host_overrides = crate::remote::db::RemoteHostOverrides { + rest: options.host_override, + sql: sql_host_override, + }; let internal = Arc::new(crate::remote::db::RemoteDatabase::try_new( &self.request.uri, &api_key, ®ion, - options.host_override, + host_overrides, client_config, storage_options.into(), self.request.read_consistency_interval, @@ -1355,6 +1589,23 @@ mod tests { assert_eq!(tc.connection.uri(), tc.uri); } + #[tokio::test] + async fn test_local_connection_rejects_sql_queries() { + let directory = tempdir().unwrap(); + let connection = connect(directory.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + assert!(matches!( + connection.execute_query_async("SELECT 1").execute().await, + Err(Error::NotSupported { .. }) + )); + assert!(matches!( + connection.describe_query(uuid::Uuid::nil()).await, + Err(Error::NotSupported { .. }) + )); + } + #[cfg(feature = "remote")] #[test] fn test_apply_env_defaults() { @@ -1380,7 +1631,11 @@ mod tests { client_secret: Some("secret".to_string()), scopes: vec!["scope".to_string()], flow: crate::remote::OAuthFlow::ClientCredentials, + client_auth_method: None, refresh_buffer_secs: None, + resource: None, + audience: None, + token_cache: None, }; let result = ConnectBuilder::new("db://my-container/my-prefix") @@ -1422,7 +1677,11 @@ mod tests { client_secret: Some("secret".to_string()), scopes: vec!["scope".to_string()], flow: crate::remote::OAuthFlow::ClientCredentials, + client_auth_method: None, refresh_buffer_secs: None, + resource: None, + audience: None, + token_cache: None, }; let client_config = crate::remote::ClientConfig { header_provider: Some( diff --git a/rust/lancedb/src/database.rs b/rust/lancedb/src/database.rs index 6c4537972..392277f63 100644 --- a/rust/lancedb/src/database.rs +++ b/rust/lancedb/src/database.rs @@ -14,12 +14,10 @@ //! * Tables may be managed by a database system (e.g. Postgres) //! * A custom table implementation (e.g. remote table, etc.) may be used -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; -use arrow_array::RecordBatch; - use lance::dataset::ReadParams; use lance_namespace::LanceNamespace; use lance_namespace::models::{ @@ -30,6 +28,9 @@ use lance_namespace::models::{ use crate::data::scannable::Scannable; use crate::error::Result; +use crate::job::Job; +use crate::materialized_view::CreateMaterializedViewRequest; +use crate::secrets::SecretInfo; use crate::table::{BaseTable, WriteOptions}; pub mod listing; @@ -206,8 +207,8 @@ pub enum ReadConsistency { /// compaction, column refresh, ...). #[derive(Debug, Clone)] pub struct JobInfo { - /// The job id -- what [`Database::get_job`] and [`Database::cancel_job`] - /// accept. + /// The job id -- what [`Database::open_job`] and + /// [`Database::cancel_job`] accept. pub job_id: String, /// The table the job runs against, without URI or namespace. pub table: String, @@ -218,8 +219,8 @@ pub struct JobInfo { pub created_at_millis: i64, } -/// A described job from [`Database::get_job`]: lifecycle state plus the -/// job-type-specific specification. +/// The server-side record behind a [`crate::job::Job`] handle: lifecycle +/// state plus the job-type-specific specification and result. #[derive(Debug, Clone)] pub struct JobDescription { pub job_id: String, @@ -230,6 +231,10 @@ pub struct JobDescription { pub creation_ms: i64, /// The job-type-specific specification. Null when the server omits it. pub spec: serde_json::Value, + /// The job-type-specific terminal result, for job types that define one. + /// `None` until the job succeeds, so a job that never terminates reports + /// its progress through [`crate::job::Job::events`] instead. + pub result: Option, /// Why the job failed, when the job is failed and the server reports a /// reason. pub failure: Option, @@ -247,6 +252,12 @@ fn function_catalog_not_supported() -> Result { }) } +fn secret_catalog_not_supported() -> Result { + Err(crate::error::Error::NotSupported { + message: "Secret operations are not supported by this database".to_string(), + }) +} + /// The `Database` trait defines the interface for database implementations. /// /// A database is responsible for managing tables and their metadata. @@ -292,13 +303,80 @@ pub trait Database: /// /// See [`CloneTableRequest`] for detailed documentation and examples. async fn clone_table(&self, request: CloneTableRequest) -> Result>; - /// Register an immutable Function version through the remote catalog. + /// Submit a Function creation job that builds an image and registers it. async fn create_function_async( &self, _request: crate::function::FunctionRegistrationRequest, ) -> Result> { function_catalog_not_supported() } + /// Create a materialized view through a remote catalog and return its + /// initial-population job. Local connections use the native declaration + /// path directly. + #[doc(hidden)] + async fn create_materialized_view_async( + &self, + _request: CreateMaterializedViewRequest, + ) -> Result { + job_op_not_supported("remote materialized-view creation") + } + /// Drop a materialized view through its resource endpoint and return its + /// cleanup job. Local connections validate the view and use table drop. + #[doc(hidden)] + async fn drop_materialized_view_async( + &self, + _name: &str, + _namespace_path: &[String], + ) -> Result { + job_op_not_supported("remote materialized-view drop") + } + /// List materialized-view names in a namespace. + #[doc(hidden)] + async fn list_materialized_views(&self, namespace_path: &[String]) -> Result> { + let mut names = Vec::new(); + let mut page_token = None; + let mut seen_page_tokens = HashSet::new(); + loop { + let response = self + .list_tables(ListTablesRequest { + id: Some(namespace_path.to_vec()), + page_token: page_token.clone(), + ..Default::default() + }) + .await?; + for name in response.tables { + let Ok(table) = self + .open_table(OpenTableRequest { + name: name.clone(), + namespace_path: namespace_path.to_vec(), + index_cache_size: None, + lance_read_params: None, + location: None, + namespace_client: None, + managed_versioning: None, + }) + .await + else { + continue; + }; + let schema = table.schema().await?; + if crate::materialized_view::materialized_view_kind(schema.metadata())?.is_some() { + names.push(name); + } + } + let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) + else { + break; + }; + if !seen_page_tokens.insert(next_page_token.clone()) { + return Err(crate::Error::Runtime { + message: "materialized-view listing repeated a page token".into(), + }); + } + page_token = Some(next_page_token); + } + Ok(names) + } /// Look up one exact immutable Function version. async fn get_function( &self, @@ -307,30 +385,83 @@ pub trait Database: ) -> Result { function_catalog_not_supported() } - /// A [`crate::job::Job`] handle for a server-side job by id, suitable for - /// waiting on or cancelling the job. The handle is constructed without a - /// server round trip; an unknown id surfaces when the handle is used. - fn job(&self, _job_id: &str) -> Result { - job_op_not_supported("job") + /// List every published immutable Function version in the remote catalog. + async fn list_functions(&self) -> Result> { + function_catalog_not_supported() + } + /// Remove the current Function name binding, retaining the object history. + async fn drop_function(&self, _name: &str, _version: &str) -> Result { + function_catalog_not_supported() + } + /// Create a named Secret in this database. Fails if the name is taken, so + /// a create can never silently become a rotation. + async fn create_secret( + &self, + _name: &str, + _value: &str, + _namespace_path: &[String], + ) -> Result<()> { + secret_catalog_not_supported() + } + /// Replace the credential behind an existing Secret. Fails if it does not + /// exist. Every Function bound to it resolves the new value from its next + /// execution, with no new Function version. + async fn alter_secret( + &self, + _name: &str, + _value: &str, + _namespace_path: &[String], + ) -> Result<()> { + secret_catalog_not_supported() + } + /// The names of every Secret in this database. + /// + /// Names only. No API path returns a stored credential, by construction + /// rather than by policy. + async fn list_secrets(&self, _namespace_path: &[String]) -> Result> { + secret_catalog_not_supported() + } + /// Drop a Secret. Functions bound to it fail at their next job, which is + /// the revocation path. + async fn drop_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<()> { + secret_catalog_not_supported() + } + /// What the database records about one Secret: its name and timestamps, + /// never its value. + async fn describe_secret(&self, _name: &str, _namespace_path: &[String]) -> Result { + secret_catalog_not_supported() + } + /// Open a job by id, returning a handle with its record already + /// populated. Fails with [`crate::Error::JobNotFound`] when the server has + /// no such job. + async fn open_job(&self, _job_id: &str) -> Result { + job_op_not_supported("open_job") } /// List server-side jobs across the database's tables. async fn list_jobs(&self) -> Result> { job_op_not_supported("list_jobs") } - /// Describe a single job by id. `None` when the server has no such job. - async fn get_job(&self, _job_id: &str) -> Result> { - job_op_not_supported("get_job") - } /// Request cancellation of a job by id. Returns true if the server /// accepted the cancellation, false if no such job exists. Cancelling an /// already-terminal job is a no-op success. async fn cancel_job(&self, _job_id: &str) -> Result { job_op_not_supported("cancel_job") } - /// The lifecycle event history of a job (all jobs when `job_id` is - /// `None`), as recorded Arrow batches. - async fn job_history(&self, _job_id: Option<&str>) -> Result> { - job_op_not_supported("job_history") + /// Start executing a SQL statement on a remote database. + async fn execute_query_async( + &self, + _query: &str, + _default_namespace_path: &[String], + ) -> Result { + Err(crate::error::Error::NotSupported { + message: "SQL is not supported by this database".to_string(), + }) + } + /// Describe a submitted SQL query by its connection-scoped id. + async fn describe_query(&self, _query_id: uuid::Uuid) -> Result { + Err(crate::error::Error::NotSupported { + message: "SQL is not supported by this database".to_string(), + }) } /// Open a table in the database async fn open_table(&self, request: OpenTableRequest) -> Result>; diff --git a/rust/lancedb/src/database/listing.rs b/rust/lancedb/src/database/listing.rs index c22b73dd7..dc1627116 100644 --- a/rust/lancedb/src/database/listing.rs +++ b/rust/lancedb/src/database/listing.rs @@ -18,7 +18,7 @@ use lance_table::io::commit::commit_handler_from_url; use object_store::local::LocalFileSystem; use snafu::ResultExt; -use crate::blob::{ensure_blob_storage_version, has_blob_columns}; +use crate::blob::ensure_blob_storage_version; use crate::connection::ConnectRequest; use crate::database::ReadConsistency; use crate::database::namespace::LanceNamespaceDatabase; @@ -512,7 +512,7 @@ impl ListingDatabase { // iter thru the query params and extract the commit store param let mut engine = None; let mut mirrored_store = None; - let mut filtered_querys = vec![]; + let mut filtered_queries = vec![]; // WARNING: specifying engine is NOT a publicly supported feature in lancedb yet // THE API WILL CHANGE @@ -528,13 +528,13 @@ impl ListingDatabase { mirrored_store = Some(value.to_string()); } else { // to owned so we can modify the url - filtered_querys.push((key.to_string(), value.to_string())); + filtered_queries.push((key.to_string(), value.to_string())); } } // Filter out the commit store query param -- it's a lancedb param url.query_pairs_mut().clear(); - url.query_pairs_mut().extend_pairs(filtered_querys); + url.query_pairs_mut().extend_pairs(filtered_queries); // Take a copy of the query string so we can propagate it to lance. // `query_pairs_mut()` leaves the URL with `Some("")` even when no // pairs survive (or none existed in the first place), so an empty @@ -827,7 +827,6 @@ impl ListingDatabase { if let Some(enable_stable_row_ids) = overrides .enable_stable_row_ids .or(self.new_table_config.enable_stable_row_ids) - .or(has_blob_columns(&data_schema).then_some(true)) { write_params.enable_stable_row_ids = enable_stable_row_ids; } @@ -897,11 +896,11 @@ impl Database for ListingDatabase { } async fn read_consistency(&self) -> Result { - if let Some(read_consistency_inverval) = self.read_consistency_interval { - if read_consistency_inverval.is_zero() { + if let Some(interval) = self.read_consistency_interval { + if interval.is_zero() { Ok(ReadConsistency::Strong) } else { - Ok(ReadConsistency::Eventual(read_consistency_inverval)) + Ok(ReadConsistency::Eventual(interval)) } } else { Ok(ReadConsistency::Manual) @@ -1034,10 +1033,10 @@ impl Database for ListingDatabase { return self.namespace_database().create_table(request).await; } // Use provided location if available, otherwise derive from table name - let table_uri = request - .location - .clone() - .unwrap_or_else(|| self.table_uri(&request.name).unwrap()); + let table_uri = match request.location.clone() { + Some(location) => location, + None => self.table_uri(&request.name)?, + }; let mut write_params = request .write_options @@ -1150,10 +1149,10 @@ impl Database for ListingDatabase { return self.namespace_database().open_table(request).await; } // Use provided location if available, otherwise derive from table name - let table_uri = request - .location - .clone() - .unwrap_or_else(|| self.table_uri(&request.name).unwrap()); + let table_uri = match request.location.clone() { + Some(location) => location, + None => self.table_uri(&request.name)?, + }; // Only modify the storage options if we actually have something to // inherit. There is a difference between storage_options=None and @@ -1697,6 +1696,64 @@ mod tests { ); } + /// The names a table cannot have. A name is what the database builds the table's + /// location out of, so one it cannot build a location from is refused rather than + /// turned into some other path. + const INVALID_TABLE_NAMES: [&str; 4] = ["", "has space", "bad/name", "a!b"]; + + /// Creating a table under an invalid name is an error the caller can handle, not a + /// panic: the name comes from the caller, and the bindings turn the error into their + /// own (`ValueError` in Python). + #[tokio::test] + async fn test_create_table_rejects_invalid_names() { + let (_tempdir, db) = setup_database().await; + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + + for name in INVALID_TABLE_NAMES { + let result = db + .create_table(CreateTableRequest { + name: name.to_string(), + namespace_path: vec![], + data: Box::new(RecordBatch::new_empty(schema.clone())) as Box, + mode: CreateTableMode::Create, + write_options: Default::default(), + location: None, + namespace_client: None, + }) + .await; + + assert!( + matches!(result, Err(Error::InvalidTableName { .. })), + "creating {name:?} did not report an invalid table name" + ); + } + } + + /// Opening a table under an invalid name is likewise an error rather than a panic. + #[tokio::test] + async fn test_open_table_rejects_invalid_names() { + let (_tempdir, db) = setup_database().await; + + for name in INVALID_TABLE_NAMES { + let result = db + .open_table(OpenTableRequest { + name: name.to_string(), + namespace_path: vec![], + index_cache_size: None, + lance_read_params: None, + location: None, + namespace_client: None, + managed_versioning: None, + }) + .await; + + assert!( + matches!(result, Err(Error::InvalidTableName { .. })), + "opening {name:?} did not report an invalid table name" + ); + } + } + async fn setup_database() -> (tempfile::TempDir, ListingDatabase) { let tempdir = tempdir().unwrap(); let uri = tempdir.path().to_str().unwrap(); @@ -3044,15 +3101,15 @@ mod tests { /// across platforms — see the `file://` test below). fn capture_query_like_connect(input_uri: &str) -> Option { let mut url = url::Url::parse(input_uri).unwrap(); - let mut filtered_querys = Vec::new(); + let mut filtered_queries = Vec::new(); for (key, value) in url.query_pairs() { if key == ENGINE || key == MIRRORED_STORE { continue; } - filtered_querys.push((key.to_string(), value.to_string())); + filtered_queries.push((key.to_string(), value.to_string())); } url.query_pairs_mut().clear(); - url.query_pairs_mut().extend_pairs(filtered_querys); + url.query_pairs_mut().extend_pairs(filtered_queries); url.query().filter(|q| !q.is_empty()).map(|s| s.to_string()) } diff --git a/rust/lancedb/src/database/namespace.rs b/rust/lancedb/src/database/namespace.rs index 250d933f6..6bca29476 100644 --- a/rust/lancedb/src/database/namespace.rs +++ b/rust/lancedb/src/database/namespace.rs @@ -3,6 +3,7 @@ //! Namespace-based database implementation that delegates table management to lance-namespace +use lance_datafusion::utils::StreamingWriteSource; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; @@ -23,7 +24,7 @@ use lance_namespace_impls::ConnectBuilder; use lance_table::io::commit::CommitHandler; use lance_table::io::commit::external_manifest::ExternalManifestCommitHandler; -use crate::blob::{ensure_blob_storage_version, has_blob_columns}; +use crate::blob::ensure_blob_storage_version; use crate::connection::NamespaceClientPushdownOperation; use crate::database::ReadConsistency; use crate::database::listing::{NewTableConfig, take_request_creation_overrides}; @@ -217,7 +218,6 @@ impl LanceNamespaceDatabase { if let Some(enable_stable_row_ids) = overrides .enable_stable_row_ids .or(self.new_table_config.enable_stable_row_ids) - .or(has_blob_columns(data_schema.as_ref()).then_some(true)) { params.enable_stable_row_ids = enable_stable_row_ids; } @@ -251,11 +251,11 @@ impl Database for LanceNamespaceDatabase { } async fn read_consistency(&self) -> Result { - if let Some(read_consistency_inverval) = self.read_consistency_interval { - if read_consistency_inverval.is_zero() { + if let Some(interval) = self.read_consistency_interval { + if interval.is_zero() { Ok(ReadConsistency::Strong) } else { - Ok(ReadConsistency::Eventual(read_consistency_inverval)) + Ok(ReadConsistency::Eventual(interval)) } } else { Ok(ReadConsistency::Manual) @@ -305,6 +305,10 @@ impl Database for LanceNamespaceDatabase { } async fn create_table(&self, request: DbCreateTableRequest) -> Result> { + // Refuse a bad declaration before the namespace records a table. + crate::table::computed_columns::ensure_declarations_are_planned( + &request.data.arrow_schema(), + )?; let mut table_id = request.namespace_path.clone(); table_id.push(request.name.clone()); let mut existing_table = None; @@ -539,9 +543,7 @@ impl Database for LanceNamespaceDatabase { self.namespace .drop_table(drop_request) .await - .map_err(|e| Error::Runtime { - message: format!("Failed to drop table: {}", e), - })?; + .map_err(|e| map_namespace_lance_error(e, name))?; Ok(()) } @@ -1495,6 +1497,15 @@ mod tests { .expect("Failed to list tables"); assert!(!table_names_after.contains(&"drop_test".to_string())); + let error = conn + .drop_table("drop_test", &["test_ns".into()]) + .await + .expect_err("dropping a missing table should fail"); + assert!( + matches!(error, Error::TableNotFound { ref name, .. } if name == "drop_test"), + "expected TableNotFound, got: {error:?}" + ); + // Verify: Cannot open dropped table let open_result = conn.open_table("drop_test").execute().await; assert!(open_result.is_err()); diff --git a/rust/lancedb/src/dataloader/permutation/reader.rs b/rust/lancedb/src/dataloader/permutation/reader.rs index 9757dc552..015c3a17d 100644 --- a/rust/lancedb/src/dataloader/permutation/reader.rs +++ b/rust/lancedb/src/dataloader/permutation/reader.rs @@ -31,7 +31,7 @@ use lance::io::RecordBatchStream; use lance_arrow::RecordBatchExt; use lance_core::ROW_ID; use lance_core::error::LanceOptionExt; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; /// Reads a permutation of a source table based on row IDs stored in a separate table @@ -234,7 +234,14 @@ impl PermutationReader { .expect_ok()? .values(); - let in_list: Vec = row_ids.iter().map(|id| lit(*id)).collect(); + let mut unique_row_ids = HashSet::with_capacity(num_rows); + let in_list: Vec = row_ids + .iter() + .copied() + .filter(|row_id| unique_row_ids.insert(*row_id)) + .map(lit) + .collect(); + let num_unique_row_ids = unique_row_ids.len(); let base_query = QueryRequest { filter: Some(QueryFilter::Datafusion(col(ROW_ID).in_list(in_list, false))), @@ -247,7 +254,7 @@ impl PermutationReader { .query( &AnyQuery::Query(base_query), QueryExecutionOptions { - max_batch_length: num_rows as u32, + max_batch_length: num_unique_row_ids as u32, ..Default::default() }, ) @@ -262,9 +269,9 @@ impl PermutationReader { }); } - if batches.iter().map(|b| b.num_rows()).sum::() != num_rows { + if batches.iter().map(|b| b.num_rows()).sum::() != num_unique_row_ids { return Err(Error::InvalidInput { - message: "Base table returned different number of rows than the number of row IDs" + message: "Base table returned a different number of rows than the number of unique row IDs" .to_string(), }); } @@ -504,6 +511,7 @@ impl PermutationReader { let table = Table::from(self.base_table.clone()); let batches = table .take_offsets(offsets.to_vec()) + .preserve_order() .select(selection.clone()) .execute() .await? @@ -803,10 +811,10 @@ mod tests { .unwrap(); // Take offsets in reverse order and verify returned rows match that order - let offsets = vec![5, 3, 1, 0]; + let offsets = vec![5, 3, 5, 1, 0]; let batch = reader.take_offsets(&offsets, Select::All).await.unwrap(); - assert_eq!(batch.num_rows(), 4); + assert_eq!(batch.num_rows(), 5); let idx_values = batch .column(0) @@ -820,6 +828,52 @@ mod tests { assert_eq!(idx_values, expected); } + #[tokio::test] + async fn test_take_offsets_preserves_repeated_rows_in_permutation() { + let base_table = lance_datagen::gen_batch() + .col("idx", lance_datagen::array::step::()) + .into_mem_table("tbl", RowCount::from(5), BatchCount::from(1)) + .await; + let base_row_ids = collect_column::(&base_table, "_rowid").await; + let permutation_row_ids = vec![ + base_row_ids[3], + base_row_ids[1], + base_row_ids[3], + base_row_ids[2], + ]; + let permutation_batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![ + Field::new("row_id", DataType::UInt64, false), + Field::new(SPLIT_ID_COLUMN, DataType::UInt64, false), + ])), + vec![ + Arc::new(UInt64Array::from(permutation_row_ids)), + Arc::new(UInt64Array::from(vec![0; 4])), + ], + ) + .unwrap(); + let permutation_table = virtual_table("row_ids", &permutation_batch).await; + let reader = PermutationReader::try_from_tables( + base_table.base_table().clone(), + permutation_table.base_table().clone(), + 0, + ) + .await + .unwrap(); + + let batch = reader + .take_offsets(&[0, 1, 2, 3], Select::All) + .await + .unwrap(); + let idx_values = batch + .column(0) + .as_primitive::() + .values() + .to_vec(); + + assert_eq!(idx_values, vec![3, 1, 3, 2]); + } + #[tokio::test] async fn test_take_offsets_with_column_selection() { let (base_table, row_ids_table, row_ids) = setup_permutation_tables(10).await; @@ -883,17 +937,17 @@ mod tests { .unwrap(); // With no permutation table, take_offsets uses the base table directly - let offsets = vec![0, 2, 4, 6]; + let offsets = vec![0, 2, 0, 4, 6]; let batch = reader.take_offsets(&offsets, Select::All).await.unwrap(); - assert_eq!(batch.num_rows(), 4); + assert_eq!(batch.num_rows(), 5); let idx_values = batch .column(0) .as_primitive::() .values() .to_vec(); - assert_eq!(idx_values, vec![0, 2, 4, 6]); + assert_eq!(idx_values, vec![0, 2, 0, 4, 6]); } #[tokio::test] diff --git a/rust/lancedb/src/error.rs b/rust/lancedb/src/error.rs index be4641388..f6a2df9a6 100644 --- a/rust/lancedb/src/error.rs +++ b/rust/lancedb/src/error.rs @@ -102,6 +102,8 @@ pub enum Error { }, #[snafu(display("Job{} was cancelled", job_id.as_ref().map(|id| format!(" {id}")).unwrap_or_default()))] JobCancelled { job_id: Option }, + #[snafu(display("Job '{job_id}' was not found"))] + JobNotFound { job_id: String }, // 3rd party / external errors #[snafu(display("object_store error: {source}"))] diff --git a/rust/lancedb/src/function.rs b/rust/lancedb/src/function.rs index 5366d984e..0d593585a 100644 --- a/rust/lancedb/src/function.rs +++ b/rust/lancedb/src/function.rs @@ -5,7 +5,7 @@ //! backend-neutral terminal result of a computed-column refresh. //! //! This module contains client/wire values only. Catalog persistence, -//! environment bake, and execution are owned by Sophon. +//! environment bake, secret resolution, and execution are owned by Sophon. use std::collections::BTreeMap; @@ -13,8 +13,12 @@ use serde::de::{self, DeserializeOwned}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; +use crate::secrets::SecretBinding; use crate::{Error, Result}; +/// Semantic Function type for a Blob v2 value. +pub const FUNCTION_BLOB_V2_TYPE: &str = "blob_v2"; + fn invalid_json(error: impl std::fmt::Display) -> Error { Error::InvalidInput { message: format!("invalid remote Function JSON: {error}"), @@ -85,10 +89,18 @@ fn application_has_unknown_nested_fields(value: &Value) -> bool { let Some(application) = value.as_object() else { return false; }; - if application - .get("function") - .is_some_and(|value| has_unknown_keys(value, &["name", "version"])) - { + if application.get("function").is_some_and(|value| { + has_unknown_keys( + value, + &[ + "name", + "object_id", + "location", + "version", + "manifest_digest", + ], + ) + }) { return true; } if application @@ -207,6 +219,33 @@ pub enum PythonRuntimeSpec { environment: PythonEnvironmentSpec, env: BTreeMap, }, + /// The GPU-enabled Sophon-managed Python runtime. + /// + /// # Examples + /// + /// ``` + /// use std::collections::BTreeMap; + /// use lancedb::function::{PythonEnvironmentSpec, PythonRuntimeSpec}; + /// + /// let runtime = PythonRuntimeSpec::PythonV2 { + /// python_version: "3.12".to_string(), + /// environment: PythonEnvironmentSpec { + /// kind: "pip".to_string(), + /// packages: vec!["cupy-cuda12x".to_string()], + /// channels: Vec::new(), + /// path: None, + /// modules: Vec::new(), + /// image: None, + /// }, + /// env: BTreeMap::new(), + /// }; + /// assert!(runtime.requires_gpu()); + /// ``` + PythonV2 { + python_version: String, + environment: PythonEnvironmentSpec, + env: BTreeMap, + }, /// A runtime kind introduced by a newer server. /// /// Unknown payload fields are intentionally not retained because the @@ -219,22 +258,27 @@ impl PythonRuntimeSpec { pub fn kind(&self) -> &str { match self { Self::Python { .. } => "python", + Self::PythonV2 { .. } => "python_v2", Self::Unrecognized { kind } => kind, } } - /// The Python version for the V1 runtime, or `None` for an unknown kind. + /// The Python version for a known Python runtime, or `None` for an unknown kind. pub fn python_version(&self) -> Option<&str> { match self { - Self::Python { python_version, .. } => Some(python_version), + Self::Python { python_version, .. } | Self::PythonV2 { python_version, .. } => { + Some(python_version) + } Self::Unrecognized { .. } => None, } } - /// The Python environment for the V1 runtime, or `None` for an unknown kind. + /// The Python environment for a known Python runtime, or `None` for an unknown kind. pub fn environment(&self) -> Option<&PythonEnvironmentSpec> { match self { - Self::Python { environment, .. } => Some(environment), + Self::Python { environment, .. } | Self::PythonV2 { environment, .. } => { + Some(environment) + } Self::Unrecognized { .. } => None, } } @@ -242,38 +286,73 @@ impl PythonRuntimeSpec { /// Environment variables, or `None` for an unknown kind. pub fn env(&self) -> Option<&BTreeMap> { match self { - Self::Python { env, .. } => Some(env), + Self::Python { env, .. } | Self::PythonV2 { env, .. } => Some(env), Self::Unrecognized { .. } => None, } } + + /// Whether the runtime requires a GPU selected by the execution platform. + pub fn requires_gpu(&self) -> bool { + matches!(self, Self::PythonV2 { .. }) + } } #[derive(Deserialize)] -struct PythonRuntimeWire { - kind: String, - #[serde(default)] - python_version: Option, - #[serde(default)] - environment: Option, +struct PythonRuntimeV1Wire { + python_version: String, + environment: PythonEnvironmentSpec, #[serde(default)] env: BTreeMap, + #[serde(default)] + gpu: Option, +} + +#[derive(Deserialize)] +struct PythonRuntimeV2Wire { + python_version: String, + environment: PythonEnvironmentSpec, + #[serde(default)] + env: BTreeMap, + gpu: bool, } impl<'de> Deserialize<'de> for PythonRuntimeSpec { fn deserialize>(deserializer: D) -> std::result::Result { - let wire = PythonRuntimeWire::deserialize(deserializer)?; - if wire.kind == "python" { - Ok(Self::Python { - python_version: wire - .python_version - .ok_or_else(|| de::Error::missing_field("python_version"))?, - environment: wire - .environment - .ok_or_else(|| de::Error::missing_field("environment"))?, - env: wire.env, - }) - } else { - Ok(Self::Unrecognized { kind: wire.kind }) + let value = Value::deserialize(deserializer)?; + let kind = value + .get("kind") + .ok_or_else(|| de::Error::missing_field("kind"))? + .as_str() + .ok_or_else(|| de::Error::custom("runtime.kind must be a string"))? + .to_string(); + match kind.as_str() { + "python" => { + let wire: PythonRuntimeV1Wire = + serde_json::from_value(value).map_err(de::Error::custom)?; + if wire.gpu.is_some() { + return Err(de::Error::custom( + "python runtime with gpu requires kind='python_v2'", + )); + } + Ok(Self::Python { + python_version: wire.python_version, + environment: wire.environment, + env: wire.env, + }) + } + "python_v2" => { + let wire: PythonRuntimeV2Wire = + serde_json::from_value(value).map_err(de::Error::custom)?; + if !wire.gpu { + return Err(de::Error::custom("runtime.gpu must be true")); + } + Ok(Self::PythonV2 { + python_version: wire.python_version, + environment: wire.environment, + env: wire.env, + }) + } + _ => Ok(Self::Unrecognized { kind }), } } } @@ -287,6 +366,8 @@ impl Serialize for PythonRuntimeSpec { environment: &'a PythonEnvironmentSpec, #[serde(skip_serializing_if = "BTreeMap::is_empty")] env: &'a BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + gpu: Option, } #[derive(Serialize)] @@ -304,6 +385,19 @@ impl Serialize for PythonRuntimeSpec { python_version, environment, env, + gpu: None, + } + .serialize(serializer), + Self::PythonV2 { + python_version, + environment, + env, + } => PythonRuntimeRef { + kind: "python_v2", + python_version, + environment, + env, + gpu: Some(true), } .serialize(serializer), Self::Unrecognized { kind } => UnrecognizedRuntimeRef { kind }.serialize(serializer), @@ -311,49 +405,86 @@ impl Serialize for PythonRuntimeSpec { } } -/// Immutable Function version returned by the Enterprise catalog. -/// -/// Scheduling resources, priority, concurrency, and retry policy belong to -/// the submitting Job and are not part of this identity. +/// A complete OCI Function image. Its digest is independent of catalog names. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionImage { + pub manifest_digest: String, + pub descriptor: Value, + pub source: bool, +} + +fn deserialize_object_version<'de, D: Deserializer<'de>>( + deserializer: D, +) -> std::result::Result { + let value = String::deserialize(deserializer)?; + match value.parse::() { + Ok(number) if number > 0 && number.to_string() == value => Ok(value), + _ => Err(de::Error::custom( + "Function version must be a canonical positive uint64", + )), + } +} + +/// One immutable Function object revision and its executable artifact. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionVersion { name: String, + object_id: String, + location: String, + #[serde(deserialize_with = "deserialize_object_version")] version: String, - artifact: FunctionArtifact, + image: FunctionImage, signature: FunctionSignature, - runtime: PythonRuntimeSpec, - runtime_digest: String, - environment_digest: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + secret_bindings: Vec, created_at: String, + metadata: BTreeMap, + disabled: bool, } impl FunctionVersion { + pub fn object_id(&self) -> &str { + &self.object_id + } + pub fn location(&self) -> &str { + &self.location + } + pub fn metadata(&self) -> &BTreeMap { + &self.metadata + } + pub fn disabled(&self) -> bool { + self.disabled + } + pub fn reference(&self) -> FunctionVersionRef { + FunctionVersionRef { + name: self.name.clone(), + object_id: self.object_id.clone(), + location: self.location.clone(), + version: self.version.clone(), + manifest_digest: self.image.manifest_digest.clone(), + } + } pub fn name(&self) -> &str { &self.name } - pub fn version(&self) -> &str { &self.version } - - pub fn artifact(&self) -> &FunctionArtifact { - &self.artifact + pub fn image(&self) -> &FunctionImage { + &self.image } - pub fn signature(&self) -> &FunctionSignature { &self.signature } - pub fn runtime(&self) -> &PythonRuntimeSpec { - &self.runtime - } - - pub fn runtime_digest(&self) -> &str { - &self.runtime_digest - } - - pub fn environment_digest(&self) -> &str { - &self.environment_digest + /// Declared environment variable name to the Secret each one resolves. + /// + /// Bindings are part of this version's identity; the credentials behind + /// them are not, and resolve at execution. Rotating a bound Secret + /// therefore changes what the same version runs with, and no value has a + /// field in this model. + pub fn secret_bindings(&self) -> &[SecretBinding] { + &self.secret_bindings } pub fn created_at(&self) -> &str { @@ -397,12 +528,21 @@ pub struct FunctionArtifactRequest { } /// Stable request envelope for remote immutable Function registration. +/// +/// Credential values deliberately have no field here. The only secret-shaped +/// thing a client sends is `secret_bindings`: the name of a Secret the +/// database already holds, which Sophon resolves inside the remote runtime. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionRegistrationRequest { pub name: String, pub artifact: FunctionArtifactRequest, pub signature: FunctionSignature, pub runtime: PythonRuntimeSpec, + /// Declared environment variable name to the Secret it binds. A binding is + /// a reference: whether the Secret exists is answered when a column is + /// declared against this version, not here. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secret_bindings: Vec, } impl_json!(FunctionRegistrationRequest); @@ -411,7 +551,11 @@ impl_json!(FunctionRegistrationRequest); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionVersionRef { pub name: String, + pub object_id: String, + pub location: String, + #[serde(deserialize_with = "deserialize_object_version")] pub version: String, + pub manifest_digest: String, } /// Parameter binding in a FunctionApplication. @@ -497,8 +641,8 @@ pub struct InputBinding { /// Ordered result-field to table-field mapping for a Function binding. /// -/// Assignment state is not part of the Slice 1 client contract. During the -/// NULL transition there is no public Lance cell-flag identifier to persist. +/// `nullable` describes the logical Function result. Physical computed-column +/// fields remain nullable while unassigned. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OutputMapping { pub result_field: String, @@ -509,6 +653,14 @@ pub struct OutputMapping { pub nullable: bool, } +/// Internal physical column preserving the parent validity of a flattened +/// named-struct result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssignmentMapping { + pub output_name: String, + pub output_field_id: i32, +} + /// Immutable Function binding persisted by the Enterprise table service. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionBinding { @@ -516,6 +668,8 @@ pub struct FunctionBinding { function: FunctionVersionRef, inputs: Vec, outputs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + assignment: Option, /// Exact Arrow schema presented to the Function, encoded with the Lance /// Namespace Arrow JSON representation. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -542,6 +696,10 @@ impl FunctionBinding { &self.outputs } + pub fn assignment(&self) -> Option<&AssignmentMapping> { + self.assignment.as_ref() + } + pub fn input_schema(&self) -> Option<&Value> { self.input_schema.as_ref() } @@ -589,7 +747,7 @@ impl_json!(RefreshColumnResult); #[cfg(test)] mod conda_environment_tests { - use super::PythonEnvironmentSpec; + use super::{PythonEnvironmentSpec, PythonRuntimeSpec}; #[test] fn conda_channels_round_trip_and_pip_stays_bare() { @@ -608,4 +766,122 @@ mod conda_environment_tests { serde_json::from_str(r#"{"kind":"pip","packages":["numpy"]}"#).unwrap(); assert!(!serde_json::to_string(&pip).unwrap().contains("channels")); } + + #[test] + fn gpu_python_runtime_marker_round_trips_and_validates() { + let runtime: PythonRuntimeSpec = serde_json::from_str( + r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":true}"#, + ) + .unwrap(); + assert_eq!(runtime.kind(), "python_v2"); + assert!(runtime.requires_gpu()); + assert_eq!( + super::canonical_json(&runtime).unwrap(), + r#"{"environment":{"kind":"pip"},"gpu":true,"kind":"python_v2","python_version":"3.12"}"# + ); + + for invalid in [ + r#"{"kind":"python","python_version":"3.12","environment":{"kind":"pip"},"gpu":true}"#, + r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"}}"#, + r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":1}"#, + r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":false}"#, + r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":"true"}"#, + r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":"H100"}"#, + ] { + assert!(serde_json::from_str::(invalid).is_err()); + } + } + + #[test] + fn unknown_runtime_discards_payload_before_known_field_validation() { + for encoded in [ + r#"{"kind":"python_v3","gpu":{"model":"H100"}}"#, + r#"{"kind":"python_v3","resources":[]}"#, + r#"{"kind":"python_v3","python_version":3.15,"environment":{"kind":[]}}"#, + ] { + let runtime: PythonRuntimeSpec = serde_json::from_str(encoded).unwrap(); + assert_eq!(runtime.kind(), "python_v3"); + assert_eq!( + super::canonical_json(&runtime).unwrap(), + r#"{"kind":"python_v3"}"# + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Canonical form is what the FunctionVersion hash is taken over, so key + /// order must come from the keys and not from however serde happened to + /// emit them. Nesting is included because the sort is recursive. + #[test] + fn canonical_json_sorts_keys_at_every_depth() { + let value = serde_json::json!({ + "runtime": {"kind": "python", "env": {"B": "2", "A": "1"}}, + "artifact": {"digest": "sha256:x"}, + "name": "embed", + }); + let mut out = String::new(); + write_canonical_json(&value, &mut out).expect("canonical JSON"); + + assert_eq!( + out, + r#"{"artifact":{"digest":"sha256:x"},"name":"embed","runtime":{"env":{"A":"1","B":"2"},"kind":"python"}}"# + ); + } + + /// Arrays are ordered by the caller, so canonicalization must leave them + /// alone -- sorting them would change what a signature means. + #[test] + fn canonical_json_preserves_array_order() { + let value = serde_json::json!({"inputs": ["b", "a", "c"]}); + let mut out = String::new(); + write_canonical_json(&value, &mut out).expect("canonical JSON"); + + assert_eq!(out, r#"{"inputs":["b","a","c"]}"#); + } + + /// A float has no single canonical spelling, so two clients could hash the + /// same literal differently. Rejected at any depth rather than rounded. + #[test] + fn validate_literal_rejects_floats_at_any_depth() { + for value in [ + serde_json::json!(1.5), + serde_json::json!([1, [2, 3.5]]), + serde_json::json!({"a": {"b": 0.25}}), + ] { + let error = validate_literal(&value).expect_err("floats are not canonical"); + assert!( + error.to_string().contains("floating-point"), + "unexpected error: {error}" + ); + } + + for value in [ + serde_json::json!(1), + serde_json::json!("1.5"), + serde_json::json!([1, {"a": true}]), + serde_json::json!(null), + ] { + validate_literal(&value).expect("non-float literals are canonical"); + } + } + + /// Unknown keys are how a newer server's payload reaches an older client, + /// so the check has to be exact about which level it is looking at. + #[test] + fn has_unknown_keys_only_inspects_the_level_it_is_given() { + let value = serde_json::json!({"name": "embed", "version": "fv_1"}); + assert!(!has_unknown_keys(&value, &["name", "version"])); + assert!(has_unknown_keys(&value, &["name"])); + + // A nested unknown is not this level's business. + let nested = serde_json::json!({"name": {"unexpected": 1}}); + assert!(!has_unknown_keys(&nested, &["name"])); + + // A non-object has no keys to be unknown. + assert!(!has_unknown_keys(&serde_json::json!("embed"), &["name"])); + } } diff --git a/rust/lancedb/src/index.rs b/rust/lancedb/src/index.rs index c693dc056..cdc973044 100644 --- a/rust/lancedb/src/index.rs +++ b/rust/lancedb/src/index.rs @@ -13,7 +13,10 @@ use crate::index::vector::IvfRqIndexBuilder; use crate::{DistanceType, Error, Result, job::Job, table::BaseTable}; use self::{ - scalar::{BTreeIndexBuilder, BitmapIndexBuilder, FmIndexBuilder, LabelListIndexBuilder}, + scalar::{ + BTreeIndexBuilder, BitmapIndexBuilder, BloomFilterIndexBuilder, FmIndexBuilder, + LabelListIndexBuilder, NGramIndexBuilder, RTreeIndexBuilder, ZoneMapIndexBuilder, + }, vector::{ IvfHnswFlatIndexBuilder, IvfHnswPqIndexBuilder, IvfHnswSqIndexBuilder, IvfPqIndexBuilder, IvfSqIndexBuilder, @@ -54,6 +57,22 @@ pub enum Index { /// substrings of the raw bytes, unlike the tokenized [`Index::FTS`] index. Fm(FmIndexBuilder), + /// A `ZoneMap` index stores min/max summaries for ranges of rows. + /// + /// It can accelerate range filters by skipping zones whose min/max values + /// prove they cannot match the predicate. + ZoneMap(ZoneMapIndexBuilder), + + /// An NGram index accelerates substring and pattern filters on UTF-8 strings. + NGram(NGramIndexBuilder), + + /// A Bloom filter index skips groups of scalar values that cannot match a filter. + BloomFilter(BloomFilterIndexBuilder), + + /// An R-tree index accelerates spatial intersection filters on GeoArrow geometries. + /// Native creation requires the `geo` feature. + RTree(RTreeIndexBuilder), + /// Full text search index using BM25. /// /// The posting block size defaults to 128. Supported values are 128 and 256; @@ -341,6 +360,14 @@ pub enum IndexType { LabelList, #[serde(alias = "FM", alias = "FMINDEX", alias = "FMIndex")] Fm, + #[serde(alias = "ZONEMAP", alias = "ZONE_MAP")] + ZoneMap, + #[serde(alias = "NGRAM", alias = "N_GRAM")] + NGram, + #[serde(alias = "BLOOM_FILTER", alias = "BLOOMFILTER")] + BloomFilter, + #[serde(alias = "RTREE", alias = "R_TREE")] + RTree, // FTS #[serde(alias = "INVERTED", alias = "Inverted")] FTS, @@ -362,6 +389,10 @@ impl std::fmt::Display for IndexType { Self::Bitmap => write!(f, "BITMAP"), Self::LabelList => write!(f, "LABEL_LIST"), Self::Fm => write!(f, "FM"), + Self::ZoneMap => write!(f, "ZONEMAP"), + Self::NGram => write!(f, "NGRAM"), + Self::BloomFilter => write!(f, "BLOOM_FILTER"), + Self::RTree => write!(f, "RTREE"), Self::FTS => write!(f, "FTS"), Self::Unknown => write!(f, "UNKNOWN"), } @@ -377,6 +408,10 @@ impl std::str::FromStr for IndexType { "BITMAP" => Ok(Self::Bitmap), "LABEL_LIST" | "LABELLIST" => Ok(Self::LabelList), "FM" | "FMINDEX" => Ok(Self::Fm), + "ZONEMAP" | "ZONE_MAP" => Ok(Self::ZoneMap), + "NGRAM" | "N_GRAM" => Ok(Self::NGram), + "BLOOM_FILTER" | "BLOOMFILTER" => Ok(Self::BloomFilter), + "RTREE" | "R_TREE" => Ok(Self::RTree), "FTS" | "INVERTED" => Ok(Self::FTS), "IVF_FLAT" => Ok(Self::IvfFlat), "IVF_SQ" => Ok(Self::IvfSq), @@ -482,3 +517,34 @@ pub struct IndexStatistics { /// The number of parts this index is split into. pub num_indices: Option, } + +#[cfg(test)] +mod tests { + use super::IndexType; + + #[test] + fn builtin_scalar_index_type_names() { + for (index_type, canonical, aliases) in [ + (IndexType::NGram, "NGRAM", ["NGram", "NGRAM", "N_GRAM"]), + ( + IndexType::BloomFilter, + "BLOOM_FILTER", + ["BloomFilter", "BLOOMFILTER", "BLOOM_FILTER"], + ), + (IndexType::RTree, "RTREE", ["RTree", "RTREE", "R_TREE"]), + ] { + assert_eq!(index_type.to_string(), canonical); + for alias in aliases { + assert_eq!(alias.parse::().unwrap(), index_type); + assert_eq!( + alias.to_lowercase().parse::().unwrap(), + index_type + ); + assert_eq!( + serde_json::from_value::(serde_json::json!(alias)).unwrap(), + index_type + ); + } + } + } +} diff --git a/rust/lancedb/src/index/scalar.rs b/rust/lancedb/src/index/scalar.rs index dba05b776..5bfc2b4e6 100644 --- a/rust/lancedb/src/index/scalar.rs +++ b/rust/lancedb/src/index/scalar.rs @@ -60,8 +60,200 @@ pub struct LabelListIndexBuilder {} #[derive(Debug, Clone, Default, serde::Serialize)] pub struct FmIndexBuilder {} +/// Builder for a ZoneMap index. +/// +/// A ZoneMap index stores min/max summaries for ranges of rows and can +/// accelerate range predicates by pruning zones that cannot match. +/// +/// ``` +/// use lancedb::{ +/// index::{scalar::ZoneMapIndexBuilder, Index}, +/// Table, +/// }; +/// +/// # async fn create_zonemap_index(table: &Table) -> lancedb::Result<()> { +/// table +/// .create_index(&["timestamp"], Index::ZoneMap(ZoneMapIndexBuilder::default())) +/// .execute() +/// .await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct ZoneMapIndexBuilder {} + +/// Builder for an NGram index over UTF-8 strings. +/// +/// This index accelerates certain substring, `LIKE`, and regular-expression filters. +/// It uses Lance's default trigram parameters. +/// +/// ``` +/// use lancedb::index::{Index, scalar::NGramIndexBuilder}; +/// # async fn example(table: &lancedb::Table) -> lancedb::Result<()> { +/// table.create_index(&["text"], Index::NGram(NGramIndexBuilder::default())) +/// .execute().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct NGramIndexBuilder {} + +/// Builder for a Bloom filter index on scalar values. +/// +/// Bloom filters accelerate equality and membership filters by skipping groups of rows that cannot +/// match. Candidate rows are checked to remove false positives. A bloom filter is much smaller than +/// a btree or bitmap index, but not as precise. It is also limited to equality queries. +/// +/// Unset parameters use Lance's defaults. +/// +/// ``` +/// use lancedb::index::{Index, scalar::BloomFilterIndexBuilder}; +/// # async fn example(table: &lancedb::Table) -> lancedb::Result<()> { +/// let params = BloomFilterIndexBuilder::default() +/// .number_of_items(4096)? +/// .probability(0.01)?; +/// table.create_index(&["id"], Index::BloomFilter(params)) +/// .execute().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BloomFilterIndexBuilder { + #[serde(skip_serializing_if = "Option::is_none")] + number_of_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + probability: Option, +} + +impl BloomFilterIndexBuilder { + /// Set the number of rows covered by each Bloom filter. + /// + /// Must be greater than zero. Defaults to 8192, unless overridden by Lance's + /// `LANCE_BLOOMFILTER_DEFAULT_NUMBER_OF_ITEMS` environment variable. + pub fn number_of_items(mut self, number_of_items: u64) -> crate::Result { + if number_of_items == 0 { + return Err(crate::Error::InvalidInput { + message: "BloomFilter number_of_items must be greater than zero".into(), + }); + } + self.number_of_items = Some(number_of_items); + Ok(self) + } + + /// Set the desired false-positive probability for each Bloom filter. + /// + /// Must be finite and strictly between zero and one. Lower values use more + /// space. Defaults to 0.00057, unless overridden by Lance's + /// `LANCE_BLOOMFILTER_DEFAULT_PROBABILITY` environment variable. + pub fn probability(mut self, probability: f64) -> crate::Result { + if !probability.is_finite() || probability <= 0.0 || probability >= 1.0 { + return Err(crate::Error::InvalidInput { + message: "BloomFilter probability must be finite and strictly between zero and one" + .into(), + }); + } + self.probability = Some(probability); + Ok(self) + } +} + +/// Builder for an R-tree index on GeoArrow geometry columns. +/// +/// This index accelerates spatial intersection filters using geometry bounding +/// boxes. Unset parameters use Lance's defaults. Native creation requires +/// the `geo` feature; remote creation requires server support. +/// +/// ``` +/// use lancedb::index::{Index, scalar::RTreeIndexBuilder}; +/// # async fn example(table: &lancedb::Table) -> lancedb::Result<()> { +/// let params = RTreeIndexBuilder::default().page_size(1024)?; +/// table.create_index(&["geometry"], Index::RTree(params)) +/// .execute().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct RTreeIndexBuilder { + #[serde(skip_serializing_if = "Option::is_none")] + page_size: Option, +} + +impl RTreeIndexBuilder { + /// Set the maximum number of entries in each R-tree page. + /// + /// Must be at least 2. Defaults to 4096. + pub fn page_size(mut self, page_size: u32) -> crate::Result { + if page_size < 2 { + return Err(crate::Error::InvalidInput { + message: "RTree page_size must be at least 2".into(), + }); + } + self.page_size = Some(page_size); + Ok(self) + } +} + pub use lance_index::scalar::FullTextSearchQuery; pub use lance_index::scalar::InvertedIndexParams as FtsIndexBuilder; pub use lance_index::scalar::InvertedIndexParams; pub use lance_index::scalar::inverted::DocumentGranularity; pub use lance_index::scalar::inverted::query::*; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scalar_index_parameters() { + assert_eq!( + serde_json::to_value(BloomFilterIndexBuilder::default()).unwrap(), + serde_json::json!({}) + ); + assert_eq!( + serde_json::to_value(RTreeIndexBuilder::default()).unwrap(), + serde_json::json!({}) + ); + assert_eq!( + serde_json::to_value( + BloomFilterIndexBuilder::default() + .number_of_items(1) + .unwrap() + ) + .unwrap(), + serde_json::json!({"number_of_items": 1}) + ); + assert_eq!( + serde_json::to_value( + BloomFilterIndexBuilder::default() + .probability(0.01) + .unwrap() + ) + .unwrap(), + serde_json::json!({"probability": 0.01}) + ); + assert!( + BloomFilterIndexBuilder::default() + .number_of_items(0) + .is_err() + ); + for probability in [ + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + -0.1, + 0.0, + 1.0, + 1.1, + ] { + assert!( + BloomFilterIndexBuilder::default() + .probability(probability) + .is_err() + ); + } + for page_size in [0, 1] { + assert!(RTreeIndexBuilder::default().page_size(page_size).is_err()); + } + assert!(RTreeIndexBuilder::default().page_size(2).is_ok()); + } +} diff --git a/rust/lancedb/src/index/vector.rs b/rust/lancedb/src/index/vector.rs index 29e01a49b..bce77a610 100644 --- a/rust/lancedb/src/index/vector.rs +++ b/rust/lancedb/src/index/vector.rs @@ -125,7 +125,7 @@ macro_rules! impl_pq_params_setter { /// This value controls how much the vector is compressed during the quantization step. /// The more sub vectors there are the less the vector is compressed. The default is /// the dimension of the vector divided by 16. If the dimension is not evenly divisible - /// by 16 we use the dimension divded by 8. + /// by 16 we use the dimension divided by 8. /// /// The above two cases are highly preferred. Having 8 or 16 values per subvector allows /// us to use efficient SIMD instructions. diff --git a/rust/lancedb/src/job.rs b/rust/lancedb/src/job.rs index 22f1a0450..54b46fc11 100644 --- a/rust/lancedb/src/job.rs +++ b/rust/lancedb/src/job.rs @@ -3,16 +3,53 @@ //! Handles to operations a server may run asynchronously. -use std::sync::Arc; +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use arrow_array::RecordBatch; use async_trait::async_trait; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use tokio::sync::watch; use tokio::task::{AbortHandle, JoinHandle}; +use crate::database::JobDescription; use crate::error::{Error, JobFailure, Result}; +/// Which of a job's events [`Job::events`] returns. +/// +/// The handle already knows which job to ask about, so this narrows the +/// query rather than naming one. +#[derive(Debug, Clone, Default)] +pub struct JobEventsRequest { + /// Maximum event rows to return. The server applies its own default + /// (1000 rows) and maximum (10,000 rows) when this is `None`, and + /// truncates without saying so, which matters for a job with one event + /// per fragment. + pub limit: Option, + /// SQL-like filter over the event columns `state`, `updated_by`, + /// `emitted_from`, `emitted_by`, and `claim_entity`. For example + /// `state = 'claim_complete'` selects only per-claim completions. + pub filter: Option, +} + +impl JobEventsRequest { + pub fn limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } + + pub fn filter(mut self, filter: impl Into) -> Self { + self.filter = Some(filter.into()); + self + } +} + +fn job_detail_not_supported(what: &str) -> Result { + Err(Error::NotSupported { + message: format!("{what} is only available for server-side jobs"), + }) +} + /// Backend-specific tracking for an asynchronous operation. #[async_trait] pub(crate) trait JobHandle: Send + Sync { @@ -23,6 +60,15 @@ pub(crate) trait JobHandle: Send + Sync { async fn status(&self) -> Result; async fn wait(&self) -> Result; async fn cancel(&self) -> Result<()>; + /// The job's full server-side record. Backends that run the operation in + /// this process have none and keep the default. + async fn describe(&self) -> Result { + job_detail_not_supported("describing a job") + } + /// The job's recorded lifecycle events. + async fn events(&self, _request: JobEventsRequest) -> Result> { + job_detail_not_supported("job event history") + } } /// A backend-neutral successful terminal result. @@ -85,16 +131,34 @@ enum JobInner { Completed(T), } +/// What a handle last learned about its job. `state` is separate because an +/// in-process job can report one but has no server-side record behind it. +#[derive(Default)] +struct JobCache { + state: Option, + description: Option, +} + /// A handle to an operation that may still be running. /// /// The operation may already be complete when the handle is created. `T` is /// the endpoint's successful terminal result; unit-result operations use the /// default `Job<()>`. +/// +/// The detail accessors ([`Job::state`], [`Job::job_type`], ...) read what the +/// handle last observed. Submitting an operation returns only a job id, so +/// populating them eagerly would cost an extra round trip on every call: +/// +/// - [`Job::refresh`] and [`Job::status`] fetch the whole record. +/// - [`Job::wait`] records the terminal state it establishes, but not the rest +/// of the record; call [`Job::refresh`] for that. +/// - Everything is `None` until one of those runs. pub struct Job where T: Clone + Send + Sync + 'static, { inner: JobInner, + cache: RwLock, } impl std::fmt::Debug for Job @@ -102,18 +166,40 @@ where T: Clone + Send + Sync + 'static, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Job") - .field("id", &self.id()) - .field("done", &matches!(self.inner, JobInner::Completed(_))) - .finish() + let cache = self.cache_read(); + let mut out = f.debug_struct("Job"); + out.field("id", &self.id()) + .field("done", &matches!(self.inner, JobInner::Completed(_))); + if let Some(state) = &cache.state { + out.field("state", state); + } + if let Some(description) = &cache.description { + out.field("job_type", &description.job_type) + .field("creation_ms", &description.creation_ms); + if !description.spec.is_null() { + out.field("spec", &description.spec); + } + if let Some(result) = &description.result { + out.field("result", result); + } + if let Some(failure) = &description.failure { + out.field("failure", failure); + } + } + out.finish() } } impl Job<()> { - /// A job whose operation finished before the handle was created. + /// A job whose operation finished before the handle was created. Its + /// state is known without asking anyone, so the cache starts populated. pub(crate) fn new_done() -> Self { Self { inner: JobInner::Completed(()), + cache: RwLock::new(JobCache { + state: Some("finished".to_string()), + description: None, + }), } } @@ -123,8 +209,21 @@ impl Job<()> { handle, decode: Arc::new(|_| Ok(())), }, + cache: RwLock::default(), } } + + /// A handle whose record the caller has already fetched, so the detail + /// accessors answer without a second round trip. + pub(crate) fn opened(handle: Box, description: JobDescription) -> Self { + let job = Self::new(handle); + { + let mut cache = job.cache_write(); + cache.state = Some(description.state.clone()); + cache.description = Some(description); + } + job + } } impl Job @@ -138,6 +237,7 @@ where handle, decode: Arc::new(TerminalResult::decode::), }, + cache: RwLock::default(), } } } @@ -169,16 +269,124 @@ where } } + fn cache_read(&self) -> RwLockReadGuard<'_, JobCache> { + self.cache.read().unwrap_or_else(|err| err.into_inner()) + } + + fn cache_write(&self) -> RwLockWriteGuard<'_, JobCache> { + self.cache.write().unwrap_or_else(|err| err.into_inner()) + } + + /// Asks the backend for this job's current state, and for a server-side + /// job its full record, then caches the answer for the detail accessors. + /// + /// In-process operations have no server-side record, so only + /// [`Job::state`] is populated for them. + pub async fn refresh(&self) -> Result<()> { + self.refresh_state().await.map(|_| ()) + } + + /// Refreshes and reports the state, which every backend can answer. + async fn refresh_state(&self) -> Result { + let JobInner::Handle { handle, .. } = &self.inner else { + let state = "finished".to_string(); + self.cache_write().state = Some(state.clone()); + return Ok(state); + }; + match handle.describe().await { + Ok(description) => { + let state = description.state.clone(); + let mut cache = self.cache_write(); + cache.state = Some(state.clone()); + cache.description = Some(description); + Ok(state) + } + // An in-process job knows its own state and nothing more. + Err(Error::NotSupported { .. }) => { + let state = handle.status().await?; + self.cache_write().state = Some(state.clone()); + Ok(state) + } + Err(err) => Err(err), + } + } + /// The operation's current lifecycle state: "running", "finished", /// "failed", or "cancelled". /// /// A point snapshot; unlike [`Job::wait`] it does not block, raise on a /// terminal failure state, or retry. States a newer server reports that - /// this client version does not know pass through as-is. + /// this client version does not know pass through as-is. Also refreshes + /// the detail accessors. pub async fn status(&self) -> Result { + self.refresh_state().await + } + + /// The last lifecycle state this handle observed, without contacting the + /// backend. `None` until the handle has. + pub fn state(&self) -> Option { + self.cache_read().state.clone() + } + + /// The whole server-side record this handle last observed. The accessors + /// below read individual fields out of it. `None` for an in-process job, + /// which has no such record. + pub fn description(&self) -> Option { + self.cache_read().description.clone() + } + + /// The job's type, as the server names it. `None` for an in-process job. + pub fn job_type(&self) -> Option { + self.with_description(|description| description.job_type.clone()) + } + + /// When the job was created, in milliseconds since the epoch. `None` for + /// an in-process job. + pub fn creation_ms(&self) -> Option { + self.with_description(|description| description.creation_ms) + } + + /// The job-type-specific specification it was submitted with. + pub fn spec(&self) -> Option { + self.with_description(|description| description.spec.clone()) + .filter(|spec| !spec.is_null()) + } + + /// The job-type-specific terminal result, as reported data rather than the + /// typed model [`Job::wait`] returns. `None` until the job succeeds. + pub fn result(&self) -> Option { + self.with_description(|description| description.result.clone()) + .flatten() + } + + /// Why the job failed, when it failed and the server reports a reason. + pub fn failure(&self) -> Option { + self.with_description(|description| description.failure.clone()) + .flatten() + } + + fn with_description(&self, read: impl FnOnce(&JobDescription) -> R) -> Option { + self.cache_read().description.as_ref().map(read) + } + + /// This job's recorded lifecycle events. + /// + /// Unlike the detail accessors, which report a terminal result only once + /// the job reaches one, events are written as the job runs and outlive the + /// workers that produced them. A distributed job records a + /// `claim`/`claim_complete` pair per unit of work, each carrying + /// `rows_processed`, so a job that never finishes still accounts for what + /// it did. In-process operations keep no event history. + pub async fn events(&self, request: JobEventsRequest) -> Result> { match &self.inner { - JobInner::Handle { handle, .. } => handle.status().await, - JobInner::Completed(_) => Ok("finished".to_string()), + JobInner::Handle { handle, .. } => handle.events(request).await, + // The operation finished before the handle existed, so there is no + // id to query with even when a server ran it. + JobInner::Completed(_) => Err(Error::NotSupported { + message: "this operation completed before its handle was created, so it \ + carries no job id to query events with" + .to_string(), + }), } } @@ -190,8 +398,19 @@ where /// [`crate::Error::JobCancelled`] if it was cancelled. pub async fn wait(&self) -> Result { match &self.inner { - JobInner::Handle { handle, decode } => (decode)(handle.wait().await?), - JobInner::Completed(result) => Ok(result.clone()), + JobInner::Handle { handle, decode } => { + let settled = handle.wait().await; + // Waiting already established a terminal state; record it so + // the detail accessors do not need another round trip for it. + if let Some(state) = terminal_state(&settled) { + self.cache_write().state = Some(state.to_string()); + } + (decode)(settled?) + } + JobInner::Completed(result) => { + self.cache_write().state = Some("finished".to_string()); + Ok(result.clone()) + } } } @@ -224,20 +443,36 @@ where U: Clone + Send + Sync + 'static, F: Fn(T) -> U + Send + Sync + 'static, { - match self.inner { + // The mapped handle tracks the same job, so it inherits what this one + // has already learned about it. + let Self { inner, cache } = self; + match inner { JobInner::Handle { handle, decode } => Job { inner: JobInner::Handle { handle, decode: Arc::new(move |result| Ok(map((decode)(result)?))), }, + cache, }, JobInner::Completed(result) => Job { inner: JobInner::Completed(map(result)), + cache, }, } } } +/// The lifecycle state a settled [`JobHandle::wait`] implies. +fn terminal_state(settled: &Result) -> Option<&'static str> { + match settled { + Ok(_) => Some("finished"), + Err(Error::JobFailed { .. }) => Some("failed"), + Err(Error::JobCancelled { .. }) => Some("cancelled"), + // Anything else is a transport failure, not a verdict on the job. + Err(_) => None, + } +} + /// How an in-process operation ended. Cloneable so every waiter can be given /// the outcome; [`Error`] is not, so failures share one behind an [`Arc`]. #[derive(Clone)] diff --git a/rust/lancedb/src/lib.rs b/rust/lancedb/src/lib.rs index 9c3c199ff..4d9afc7c7 100644 --- a/rust/lancedb/src/lib.rs +++ b/rust/lancedb/src/lib.rs @@ -174,6 +174,7 @@ pub mod arrow; pub mod blob; +pub mod catalog; pub mod connection; pub mod data; pub mod database; @@ -195,6 +196,8 @@ pub mod query; #[cfg(feature = "remote")] pub mod remote; pub mod rerankers; +pub mod secrets; +pub mod sql; pub mod table; #[cfg(test)] pub mod test_utils; @@ -382,3 +385,9 @@ pub use lance_io::object_store::ObjectStoreRegistry; /// declaring their own (potentially mismatched) direct `datafusion` dependency. /// See . pub use datafusion; + +/// Connect to a remote catalog through its HTTP(S) root namespace endpoint. +#[cfg(feature = "remote")] +pub fn connect_catalog(endpoint: impl Into) -> catalog::ConnectCatalogBuilder { + catalog::ConnectCatalogBuilder::new(endpoint) +} diff --git a/rust/lancedb/src/materialized_view.rs b/rust/lancedb/src/materialized_view.rs index 08d6c921e..94fcb5dda 100644 --- a/rust/lancedb/src/materialized_view.rs +++ b/rust/lancedb/src/materialized_view.rs @@ -5,9 +5,10 @@ //! //! A materialized view is a table whose contents are defined by a query over //! one source table and maintained by refresh rather than by writes. Creation -//! commits an empty table carrying the kind-tagged definition in schema -//! metadata; a kind added later reads back as unrefreshable, not as a plain -//! table. Queries, indexes and search work on the view unchanged. +//! records a kind-tagged definition in schema metadata and populates the view +//! unless creation explicitly requests no data. A kind added later reads back +//! as unrefreshable, not as a plain table. Queries, indexes and search work on +//! the view unchanged. pub mod refresh; @@ -27,7 +28,13 @@ use crate::connection::Connection; use crate::database::listing::OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS; use crate::database::{CreateTableRequest, Database, OpenTableRequest}; use crate::embeddings::EmbeddingDefinition; +use crate::function::FunctionBinding; +use crate::job::Job; use crate::table::Table; +use crate::table::computed_columns::{ + FUNCTION_BINDINGS_META_KEY, computed_column_from_field, computed_columns, + ensure_declarations_are_planned, function_bindings_metadata, +}; use crate::table::refresh::quote_identifier; use crate::table::{ColumnDefinition, ColumnKind}; use crate::{Error, Result}; @@ -74,8 +81,15 @@ const EMBEDDING_FUNCTIONS_META_KEY: &str = "embedding_functions"; const COLUMN_DEFINITIONS_META_KEY: &str = "lancedb::column_definitions"; /// Value of the definition's `kind` tag for the projected `select` form. +/// Reserved for root-namespace sources; see [`NAMESPACED_SELECT_KIND`]. pub const SELECT_KIND: &str = "select"; +/// The `select` form over a namespaced source: its own kind, because released +/// readers drop unknown fields and resolve a `select` source at the root, so +/// this routes them to the [`MaterializedViewKind::Unrecognized`] refusal +/// instead of a wrong-table refresh. +pub const NAMESPACED_SELECT_KIND: &str = "namespaced_select"; + /// Which view outputs each source column is projected to directly. A column /// may be projected more than once, so each carries every name the view gives /// it, in projection order. @@ -95,6 +109,10 @@ pub struct ViewProjection { pub struct MaterializedViewDefinition { /// Name of the source table, in the same database as the view. pub source_table: String, + /// Namespace path holding the source table; empty is the root namespace. + /// A definition written before namespaced sources reads as root. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_namespace: Vec, /// The projected output columns, in view schema order. pub projections: Vec, /// SQL predicate selecting the source rows the view holds. @@ -108,6 +126,40 @@ pub struct MaterializedViewDefinition { pub inputs: Vec, } +/// The backend-independent metadata needed to open a materialized view. +#[doc(hidden)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaterializedViewInfo { + /// The parsed view definition. + pub definition: MaterializedViewDefinition, + /// The current physical incarnation, when one has been minted. + pub incarnation: Option, +} + +/// The backend-independent request used to create a remote materialized view. +#[doc(hidden)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateMaterializedViewRequest { + /// Name of the new view. + pub name: String, + /// Namespace in which to create the view. + pub namespace_path: Vec, + /// Defining SELECT query. + pub query: String, + /// Whether to skip the initial population job. + pub with_no_data: bool, +} + +/// Prefix of the internal columns holding source columns a computed column +/// reads without the view projecting them; see +/// [`PreparedDeclaration::input_column`]. +pub const INPUT_COLUMN_PREFIX: &str = "__input_"; + +/// The internal view column holding a copy of `source_column`. +pub fn input_column_name(source_column: &str) -> String { + format!("{INPUT_COLUMN_PREFIX}{source_column}") +} + /// A view definition as read back from schema metadata. Non-exhaustive so a /// kind added later is additive. #[derive(Debug, Clone, PartialEq, Eq)] @@ -129,7 +181,12 @@ pub(crate) fn definition_to_metadata(definition: &MaterializedViewDefinition) -> let mut value = serde_json::to_value(definition).map_err(|e| Error::Runtime { message: format!("failed to serialize view definition: {e}"), })?; - value["kind"] = serde_json::Value::String(SELECT_KIND.to_string()); + let kind = if definition.source_namespace.is_empty() { + SELECT_KIND + } else { + NAMESPACED_SELECT_KIND + }; + value["kind"] = serde_json::Value::String(kind.to_string()); Ok(value.to_string()) } @@ -150,15 +207,46 @@ pub fn materialized_view_kind( .get("kind") .and_then(|k| k.as_str()) .ok_or_else(|| unreadable(&"missing kind tag"))?; - if kind != SELECT_KIND { + if kind != SELECT_KIND && kind != NAMESPACED_SELECT_KIND { return Ok(Some(MaterializedViewKind::Unrecognized { kind: kind.to_string(), })); } - let definition = serde_json::from_value(value).map_err(|e| unreadable(&e))?; + let kind = kind.to_string(); + let definition: MaterializedViewDefinition = + serde_json::from_value(value).map_err(|e| unreadable(&e))?; + // No correct writer produces a kind that disagrees with its namespace. + if (kind == SELECT_KIND) != definition.source_namespace.is_empty() { + return Err(unreadable(&format!( + "kind '{kind}' does not match its source namespace {:?}", + definition.source_namespace + ))); + } Ok(Some(MaterializedViewKind::Select(definition))) } +pub(crate) fn materialized_view_info_from_metadata( + name: &str, + metadata: &HashMap, +) -> Result { + let incarnation = metadata.get(INCARNATION_META_KEY).cloned(); + match materialized_view_kind(metadata)? { + Some(MaterializedViewKind::Select(definition)) => Ok(MaterializedViewInfo { + definition, + incarnation, + }), + Some(MaterializedViewKind::Unrecognized { kind }) => Err(Error::NotSupported { + message: format!( + "materialized view '{name}' is defined by '{kind}', which this version of \ + lancedb cannot refresh" + ), + }), + None => Err(Error::NotAMaterializedView { + name: name.to_string(), + }), + } +} + /// Resolve a definition against the source schema into the view's projected /// fields, with `inputs` filled in. Everything statically checkable is /// checked here rather than at refresh time. Empty `projections` selects @@ -166,7 +254,8 @@ pub fn materialized_view_kind( pub(crate) fn plan( source_schema: SchemaRef, source_table: &str, - projections: &[(String, String)], + source_namespace: &[String], + projections: Option<&[(String, String)]>, filter: Option<&str>, limit: Option, ) -> Result<(MaterializedViewDefinition, Vec, Lineage)> { @@ -179,17 +268,16 @@ pub(crate) fn plan( }, err => err, })?; - let projections: Vec<(String, String)> = if projections.is_empty() { - source_schema + let projections: Vec<(String, String)> = match projections { + Some(projections) => projections.to_vec(), + // `SELECT *`. A source that is itself a view carries its own + // provenance column; the new view records its own, not a copy. + None => source_schema .fields() .iter() - // A source that is itself a view carries its own provenance - // column; the new view records its own, not a copy. .filter(|f| f.name() != SOURCE_ROW_ID_COLUMN) .map(|f| (f.name().clone(), quote_identifier(f.name()))) - .collect() - } else { - projections.to_vec() + .collect(), }; // A scan takes the cap as i64. Rejecting it here keeps creation and @@ -262,9 +350,16 @@ pub(crate) fn plan( message: e.to_string(), })?; - // Always nullable: what a refresh appends must fit the declared field - // whatever nullability the evaluator reports for a given batch. - let mut field = ArrowField::new(output, data_type, true); + // A projected column keeps its nullability; a computed value is + // nullable whatever the evaluator reports for a given batch. + let nullable = match projected_path(&expr).as_deref() { + Some([column]) => source_schema + .field_with_name(column) + .map(|f| f.is_nullable()) + .unwrap_or(true), + _ => true, + }; + let mut field = ArrowField::new(output, data_type, nullable); // Identity projections keep descriptive field metadata (blob markers); // computed values carry none. Structural declarations never come along. if let Some(source_field) = projected_field(&expr, &source_schema) { @@ -319,6 +414,7 @@ pub(crate) fn plan( let definition = MaterializedViewDefinition { source_table: source_table.to_string(), + source_namespace: source_namespace.to_vec(), projections: projections .into_iter() .map(|(output, expression)| ViewProjection { output, expression }) @@ -600,9 +696,15 @@ fn project_schema(schema: &ArrowSchema, columns: &[String]) -> SchemaRef { pub struct PreparedDeclaration { schema: SchemaRef, definition: MaterializedViewDefinition, + /// The source schema and the projection lineage, for placing a computed + /// column's inputs; `internal_inputs` counts the projections + /// [`PreparedDeclaration::input_column`] added after the declared ones. + source_schema: SchemaRef, + lineage: Lineage, + internal_inputs: usize, /// The source's own database: the only place /// [`PreparedDeclaration::create`] will put the view, because refresh - /// resolves the recorded source name through the view's database. + /// resolves the recorded source coordinate through the view's database. database: Arc, } @@ -620,12 +722,213 @@ impl PreparedDeclaration { &self.definition } + /// The schema the view will have: the declared columns in order, any + /// internal projections added by [`PreparedDeclaration::input_column`], + /// then [`SOURCE_ROW_ID_COLUMN`]. + pub fn schema(&self) -> &SchemaRef { + &self.schema + } + + /// The view column that holds `source_column` for a computed column to + /// read: the column the view projects it to, if any, otherwise an + /// internal projection added here, named by [`input_column_name`]. + pub fn input_column(&mut self, source_column: &str) -> Result { + if let Some(output) = self.lineage.get(source_column).and_then(|o| o.first()) { + return Ok(output.clone()); + } + let name = input_column_name(source_column); + let field = self + .source_schema + .field_with_name(source_column) + .map_err(|_| Error::InvalidInput { + message: format!("the source has no column '{source_column}' to read"), + })?; + if self.schema.field_with_name(&name).is_ok() { + return Err(Error::ColumnAlreadyExists { name }); + } + let row_id = self.row_id_index()?; + let mut fields: Vec = self + .schema + .fields() + .iter() + .map(|f| f.as_ref().clone()) + .collect(); + fields.insert( + row_id, + without_declarations(&field.as_ref().clone().with_name(name.clone())), + ); + self.definition.projections.push(ViewProjection { + output: name.clone(), + expression: quote_identifier(source_column), + }); + self.definition.inputs.push(source_column.to_string()); + self.definition.inputs.sort(); + self.definition.inputs.dedup(); + self.lineage + .entry(source_column.to_string()) + .or_default() + .push(name.clone()); + self.internal_inputs += 1; + let mut metadata = self.schema.metadata().clone(); + rewrite_column_definitions(&mut metadata, self.schema.as_ref(), &fields)?; + metadata.insert( + DEFINITION_META_KEY.to_string(), + definition_to_metadata(&self.definition)?, + ); + self.schema = Arc::new(ArrowSchema::new_with_metadata(fields, metadata)); + Ok(name) + } + + /// Add computed columns, each at its position among the declared + /// columns, with the bindings any of them name. + /// + /// Refresh never computes such a column: every row it writes carries + /// NULL there, and the declaration's owner fills it, `refresh_column` + /// for a SQL declaration. A commit that fills only computed columns is + /// the one commit on a view refresh does not treat as drift. Declarations + /// are validated over the assembled schema, and read only columns the + /// view holds (see [`PreparedDeclaration::input_column`]). + /// + /// ```no_run + /// # #![recursion_limit = "256"] + /// # use std::collections::HashMap; + /// # use arrow_schema::{DataType, Field}; + /// # use lancedb::materialized_view::prepare_declaration; + /// # use lancedb::table::computed_columns::{ + /// # COMPUTED_COLUMN_META_KEY, EXPRESSION_META_KEY, INPUTS_META_KEY, KIND_META_KEY, SQL_KIND, + /// # }; + /// # async fn declare(source: &lancedb::Table) -> Result<(), Box> { + /// let mut prepared = prepare_declaration( + /// source, + /// Some(&[("id".into(), "id".into())]), + /// None, + /// None, + /// ) + /// .await?; + /// // `text` is not projected; the view holds it internally for the column to read. + /// let text = prepared.input_column("text")?; + /// let length = Field::new("length", DataType::Int32, true).with_metadata(HashMap::from([ + /// (COMPUTED_COLUMN_META_KEY.into(), "true".into()), + /// (KIND_META_KEY.into(), SQL_KIND.into()), + /// (EXPRESSION_META_KEY.into(), format!("length({text})")), + /// (INPUTS_META_KEY.into(), format!("[\"{text}\"]")), + /// ])); + /// let view = prepared + /// .with_computed_columns(vec![(1, length)], &[])? + /// .create("lengths") + /// .await?; + /// view.refresh().execute().await?; // rows land with `length` NULL + /// view.table().refresh_column("length").await?; // filled + /// # Ok(()) + /// # } + /// ``` + pub fn with_computed_columns( + mut self, + columns: Vec<(usize, ArrowField)>, + bindings: &[FunctionBinding], + ) -> Result { + let invalid = |message: String| Error::InvalidInput { message }; + if columns.is_empty() { + return Err(invalid("at least one computed column is needed".into())); + } + if !computed_columns(&self.schema).is_empty() { + return Err(invalid( + "computed columns were already declared on this view".into(), + )); + } + if self.definition.projections.is_empty() { + return Err(invalid( + "a view of computed columns alone must read at least one source column".into(), + )); + } + let visible_count = self.visible_count(); + let mut fields: Vec = self + .schema + .fields() + .iter() + .map(|f| f.as_ref().clone()) + .collect(); + let mut columns = columns; + columns.sort_by_key(|(position, _)| *position); + for (inserted, (position, field)) in columns.iter().enumerate() { + let name = field.name().as_str(); + if name == SOURCE_ROW_ID_COLUMN + || name == ROW_ID + || name.starts_with(INPUT_COLUMN_PREFIX) + { + return Err(invalid(format!("view column name '{name}' is reserved"))); + } + if fields.iter().any(|f| f.name() == name) { + return Err(Error::ColumnAlreadyExists { + name: name.to_string(), + }); + } + if !field.is_nullable() { + return Err(invalid(format!( + "computed column '{name}' must be nullable until a refresh fills it" + ))); + } + if computed_column_from_field(field).is_none() { + return Err(invalid(format!( + "column '{name}' does not carry a computed-column declaration" + ))); + } + let limit = visible_count + inserted; + if *position > limit { + return Err(invalid(format!( + "computed column '{name}' is placed at {position}, past the view's {limit} columns" + ))); + } + // Positions index the select list, which counts the computed + // columns already inserted before this one. + fields.insert(*position, field.clone()); + } + let mut metadata = self.schema.metadata().clone(); + if !bindings.is_empty() { + metadata.insert( + FUNCTION_BINDINGS_META_KEY.to_string(), + function_bindings_metadata(bindings)?, + ); + } + rewrite_column_definitions(&mut metadata, self.schema.as_ref(), &fields)?; + let schema = ArrowSchema::new_with_metadata(fields, metadata); + ensure_declarations_are_planned(&schema)?; + self.schema = Arc::new(schema); + Ok(self) + } + + fn row_id_index(&self) -> Result { + self.schema + .index_of(SOURCE_ROW_ID_COLUMN) + .map_err(|e| Error::Runtime { + message: e.to_string(), + }) + } + + /// Columns the declaration lists: everything before the internal + /// projections and the provenance column. + fn visible_count(&self) -> usize { + self.definition.projections.len() - self.internal_inputs + + computed_columns(&self.schema).len() + } + /// Create the view table and verify it, consuming the declaration. /// - /// The view goes in the source's own database, where refresh resolves the - /// recorded source name. Stable row ids are requested at both levels and - /// verified rather than trusted; nothing is rolled back on failure. + /// The view goes at the root of the source's own database, where refresh + /// resolves the recorded source coordinate. Stable row ids are requested + /// at both levels and verified rather than trusted; nothing is rolled + /// back on failure. pub async fn create(self, name: &str) -> Result { + self.create_in(&[], name).await + } + + /// Create the view in `namespace_path`, empty for the root namespace. + /// Otherwise [`PreparedDeclaration::create`]. + pub async fn create_in( + self, + namespace_path: &[String], + name: &str, + ) -> Result { let empty: Vec> = vec![]; // Minted here, not at preparation: a declaration can be cloned and @@ -640,6 +943,7 @@ impl PreparedDeclaration { let reader: Box = Box::new(arrow_array::RecordBatchIterator::new(empty, schema)); let mut request = CreateTableRequest::new(name.to_string(), Box::new(reader)); + request.namespace_path = namespace_path.to_vec(); let write_params = request .write_options .lance_write_params @@ -678,11 +982,49 @@ impl PreparedDeclaration { } } -/// Validate a view declaration against its live source and hold what its -/// creation needs. The declaration is canonicalized through the coordinate a -/// refresh will resolve, so a handle that does not resolve back to itself is -/// rejected, as is a namespaced source. Same creation-time checks as -/// [`Connection::create_materialized_view`]. +/// Column definitions are positional over the view schema: carry each +/// field's entry to its place in `fields`, physical for a field that had none. +fn rewrite_column_definitions( + metadata: &mut HashMap, + previous: &ArrowSchema, + fields: &[ArrowField], +) -> Result<()> { + let Some(raw) = metadata.get(COLUMN_DEFINITIONS_META_KEY).cloned() else { + return Ok(()); + }; + let definitions: Vec = + serde_json::from_str(&raw).map_err(|e| Error::Runtime { + message: format!("unreadable column definitions on the view: {e}"), + })?; + let by_name: HashMap<&str, &ColumnDefinition> = previous + .fields() + .iter() + .zip(&definitions) + .map(|(field, definition)| (field.name().as_str(), definition)) + .collect(); + let rewritten: Vec = fields + .iter() + .map(|field| { + by_name + .get(field.name().as_str()) + .map(|d| (*d).clone()) + .unwrap_or(ColumnDefinition { + kind: ColumnKind::Physical, + }) + }) + .collect(); + metadata.insert( + COLUMN_DEFINITIONS_META_KEY.to_string(), + serde_json::to_string(&rewritten).map_err(|e| Error::Runtime { + message: format!("failed to serialize column definitions: {e}"), + })?, + ); + Ok(()) +} + +/// `projections` of `None` selects every source column, as `SELECT *`; +/// `Some(&[])` declares no projected column, for a view of function +/// columns alone. /// /// ```no_run /// # #![recursion_limit = "256"] @@ -690,7 +1032,7 @@ impl PreparedDeclaration { /// # async fn declare(source: &lancedb::Table) -> Result<(), Box> { /// let prepared = prepare_declaration( /// source, -/// &[("id".into(), "id".into()), ("double".into(), "value * 2".into())], +/// Some(&[("id".into(), "id".into()), ("double".into(), "value * 2".into())]), /// Some("value > 0"), /// None, /// ) @@ -701,7 +1043,7 @@ impl PreparedDeclaration { /// ``` pub async fn prepare_declaration( source: &Table, - projections: &[(String, String)], + projections: Option<&[(String, String)]>, filter: Option<&str>, limit: Option, ) -> Result { @@ -710,17 +1052,9 @@ pub async fn prepare_declaration( message: "materialized views are supported only on local databases".into(), }); }; - // The definition records the source by bare name; any other source - // form would be recorded as a name its refresh cannot resolve. - if !source.namespace().is_empty() { - return Err(Error::NotSupported { - message: format!( - "a namespaced source cannot be recorded in a view definition; \ - '{}' must be a root-namespace table", - source.name() - ), - }); - } + // Refresh resolves the source at exactly this coordinate, so the + // definition records the namespace alongside the name. + let source_namespace = source.namespace().to_vec(); let database = source .database_opt() .ok_or_else(|| Error::InvalidInput { @@ -734,7 +1068,7 @@ pub async fn prepare_declaration( let resolved = database .open_table(OpenTableRequest { name: source.name().to_string(), - namespace_path: vec![], + namespace_path: source_namespace.clone(), index_cache_size: None, lance_read_params: None, location: None, @@ -775,11 +1109,23 @@ pub async fn prepare_declaration( resolved.name(), ) .await?; + // The internal-input prefix belongs to the declaration alone; the + // replan at refresh sees those projections and must accept them. + if let Some(reserved) = projections + .unwrap_or_default() + .iter() + .find(|(output, _)| output.starts_with(INPUT_COLUMN_PREFIX)) + { + return Err(Error::InvalidInput { + message: format!("view column name '{}' is reserved", reserved.0), + }); + } let source_schema = resolved.schema().await?; let source_metadata = source_schema.metadata().clone(); let (definition, mut fields, lineage) = plan( source_schema.clone(), resolved.name(), + &source_namespace, projections, filter, limit, @@ -809,40 +1155,25 @@ pub async fn prepare_declaration( Ok(PreparedDeclaration { schema: Arc::new(ArrowSchema::new_with_metadata(fields, metadata)), definition, + source_schema, + lineage, + internal_inputs: 0, database, }) } -/// One row of [`Connection::list_materialized_views`]: a view's name and its -/// definition kind, which may be one this version cannot refresh. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MaterializedViewEntry { - /// Name of the view's table. - pub name: String, - /// The view's definition as stored. - pub kind: MaterializedViewKind, -} - -/// Materialized views are local-only; refuse a remote connection before any -/// request is made. -fn ensure_local(connection: &Connection) -> Result<()> { - if connection.uri().starts_with("db://") { - return Err(Error::NotSupported { - message: "materialized views are supported only on local databases".into(), - }); - } - Ok(()) -} - /// Builds a materialized view. Created by /// [`Connection::create_materialized_view`]. pub struct CreateMaterializedViewBuilder { connection: Connection, name: String, + namespace: Vec, source: String, + source_namespace: Vec, projections: Vec<(String, String)>, filter: Option, limit: Option, + with_no_data: bool, } impl CreateMaterializedViewBuilder { @@ -850,13 +1181,29 @@ impl CreateMaterializedViewBuilder { Self { connection, name, + namespace: Vec::new(), source, + source_namespace: Vec::new(), projections: Vec::new(), filter: None, limit: None, + with_no_data: false, } } + /// The namespace to create the view in. Defaults to the root namespace. + pub fn namespace(mut self, namespace_path: Vec) -> Self { + self.namespace = namespace_path; + self + } + + /// The namespace holding the source table; recorded in the definition + /// for refresh to resolve. Defaults to the root namespace. + pub fn source_namespace(mut self, namespace_path: Vec) -> Self { + self.source_namespace = namespace_path; + self + } + /// The view's columns, as `(name, SQL expression)` pairs. Not calling /// this selects every source column, expanded at creation time. pub fn select( @@ -882,20 +1229,102 @@ impl CreateMaterializedViewBuilder { self } - /// Create the view: an empty table carrying the definition; refresh - /// computes the rows. The source must keep stable row ids -- they hold - /// provenance across compaction, and cannot be enabled later. + /// Create only the definition and empty backing table. By default create + /// also waits for the initial refresh so the returned view is populated. + pub fn with_no_data(mut self, with_no_data: bool) -> Self { + self.with_no_data = with_no_data; + self + } + + fn query(&self) -> String { + fn quote(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) + } + + let projection = if self.projections.is_empty() { + "*".to_string() + } else { + self.projections + .iter() + .map(|(output, expression)| format!("{expression} AS {}", quote(output))) + .collect::>() + .join(", ") + }; + let source = self + .source_namespace + .iter() + .chain(std::iter::once(&self.source)) + .map(|part| quote(part)) + .collect::>() + .join("."); + let mut query = format!("SELECT {projection} FROM {source}"); + if let Some(filter) = &self.filter { + query.push_str(" WHERE "); + query.push_str(filter); + } + if let Some(limit) = self.limit { + query.push_str(&format!(" LIMIT {limit}")); + } + query + } + + /// Submit creation and initial population, returning a [`Job`] that + /// settles when the view is ready. The source must keep stable row ids -- + /// they hold provenance across compaction, and cannot be enabled later. + pub async fn execute_async(self) -> Result { + if self.connection.uri().starts_with("db://") { + return self + .connection + .database() + .create_materialized_view_async(CreateMaterializedViewRequest { + name: self.name.clone(), + namespace_path: self.namespace.clone(), + query: self.query(), + with_no_data: self.with_no_data, + }) + .await; + } + Ok(Job::spawned(tokio::spawn(async move { + self.execute_native().await.map(|_| ()) + }))) + } + + /// Create and populate the view, waiting until it is ready. pub async fn execute(self) -> Result { - ensure_local(&self.connection)?; - let source = self.connection.open_table(&self.source).execute().await?; + if !self.connection.uri().starts_with("db://") { + return self.execute_native().await; + } + let connection = self.connection.clone(); + let name = self.name.clone(); + let namespace = self.namespace.clone(); + self.execute_async().await?.wait().await?; + let table = connection + .open_table(name) + .namespace(namespace) + .execute() + .await?; + MaterializedView::from_table(table).await + } + + async fn execute_native(self) -> Result { + let source = self + .connection + .open_table(&self.source) + .namespace(self.source_namespace.clone()) + .execute() + .await?; let prepared = prepare_declaration( &source, - &self.projections, + (!self.projections.is_empty()).then_some(self.projections.as_slice()), self.filter.as_deref(), self.limit, ) .await?; - prepared.create(&self.name).await + let view = prepared.create_in(&self.namespace, &self.name).await?; + if !self.with_no_data { + view.refresh().execute().await?; + } + Ok(view) } } @@ -912,32 +1341,12 @@ impl MaterializedView { /// for a plain table, [`Error::NotSupported`] for a kind this version /// cannot refresh. pub async fn from_table(table: Table) -> Result { - // Same local-only boundary the connection-level entry points hold, - // applied before the schema read so a remote table costs no request. - if table.as_native().is_none() { - return Err(Error::NotSupported { - message: "materialized views are supported only on local databases".into(), - }); - } - let schema = table.schema().await?; - let incarnation = schema.metadata().get(INCARNATION_META_KEY).cloned(); - match materialized_view_kind(schema.metadata())? { - Some(MaterializedViewKind::Select(definition)) => Ok(Self { - table, - definition, - incarnation, - }), - Some(MaterializedViewKind::Unrecognized { kind }) => Err(Error::NotSupported { - message: format!( - "materialized view '{}' is defined by '{kind}', which this version of \ - lancedb cannot refresh", - table.name() - ), - }), - None => Err(Error::NotAMaterializedView { - name: table.name().to_string(), - }), - } + let info = table.base_table().materialized_view_info().await?; + Ok(Self { + table, + definition: info.definition, + incarnation: info.incarnation, + }) } /// The view, as the table it is. Queries, indexes and search all apply. @@ -1022,22 +1431,52 @@ impl RefreshMaterializedViewBuilder { self } + /// Submit the refresh and return a job that settles with its result. + pub async fn execute_async(self) -> Result> { + if self.view.table.as_native().is_none() { + return self + .view + .table + .base_table() + .refresh_materialized_view_async( + self.full, + self.source_version, + self.expected_incarnation.as_deref(), + ) + .await; + } + Ok(Job::spawned(tokio::spawn(async move { + refresh::execute_refresh( + &self.view.table, + self.full, + self.source_version, + self.expected_incarnation.as_deref(), + ) + .await + }))) + } + + /// Refresh the view, waiting for the job to finish. pub async fn execute(self) -> Result { - refresh::execute_refresh( - &self.view.table, - self.full, - self.source_version, - self.expected_incarnation.as_deref(), - ) - .await + if self.view.table.as_native().is_some() { + return refresh::execute_refresh( + &self.view.table, + self.full, + self.source_version, + self.expected_incarnation.as_deref(), + ) + .await; + } + self.execute_async().await?.wait().await } } impl Connection { /// Define a materialized view named `name` over `source`. /// - /// The view is created empty, with the definition recorded in its schema - /// metadata; refresh computes the rows. Local databases only. + /// The definition is recorded in schema metadata and the initial refresh + /// is completed before this method returns. Use + /// [`CreateMaterializedViewBuilder::with_no_data`] to skip population. /// /// ```no_run /// # #![recursion_limit = "256"] @@ -1049,7 +1488,7 @@ impl Connection { /// .only_if("age >= 18") /// .execute() /// .await?; - /// view.refresh().execute().await?; + /// assert_eq!(view.table().count_rows(None).await?, 1); /// # Ok(()) /// # } /// ``` @@ -1066,28 +1505,75 @@ impl Connection { &self, name: impl Into, ) -> Result { - ensure_local(self)?; let table = self.open_table(name).execute().await?; MaterializedView::from_table(table).await } - /// The materialized views in this database, unrefreshable kinds included. - /// Costs a table open per table; one that cannot be opened is skipped - /// rather than failing the listing. - pub async fn list_materialized_views(&self) -> Result> { - ensure_local(self)?; - let names = self.table_names().execute().await?; - let mut views = Vec::new(); - for name in names { - let Ok(table) = self.open_table(&name).execute().await else { - continue; - }; - let schema = table.schema().await?; - if let Some(kind) = materialized_view_kind(schema.metadata())? { - views.push(MaterializedViewEntry { name, kind }); - } + /// The names of materialized views in the root namespace. + pub async fn list_materialized_views(&self) -> Result> { + self.database().list_materialized_views(&[]).await + } + + /// Drop a materialized view. + /// + /// The view may become unavailable before its physical data is removed. + /// Use [`Connection::drop_materialized_view_async`] to retain the cleanup + /// job and wait for it explicitly. + pub async fn drop_materialized_view( + &self, + name: impl AsRef, + namespace_path: &[String], + ) -> Result<()> { + let name = name.as_ref(); + if self.uri().starts_with("db://") { + return self + .database() + .drop_materialized_view_async(name, namespace_path) + .await + .map(|_| ()); } - Ok(views) + let table = self + .open_table(name) + .namespace(namespace_path.to_vec()) + .execute() + .await?; + MaterializedView::from_table(table).await?; + self.drop_table(name, namespace_path).await + } + + /// Start dropping a materialized view and return its cleanup job. + /// + /// This validates that the named resource is a materialized view rather + /// than an ordinary table. Call [`Job::wait`] before assuming physical + /// cleanup has finished. + /// + /// ```no_run + /// # use lancedb::Connection; + /// # async fn drop_view(conn: &Connection) -> lancedb::Result<()> { + /// let job = conn.drop_materialized_view_async("daily_sales", &[]).await?; + /// job.wait().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn drop_materialized_view_async( + &self, + name: impl AsRef, + namespace_path: &[String], + ) -> Result { + let name = name.as_ref(); + if self.uri().starts_with("db://") { + return self + .database() + .drop_materialized_view_async(name, namespace_path) + .await; + } + let table = self + .open_table(name) + .namespace(namespace_path.to_vec()) + .execute() + .await?; + MaterializedView::from_table(table).await?; + self.drop_table_async(name, namespace_path).await } } @@ -1152,6 +1638,7 @@ mod tests { view.definition(), &MaterializedViewDefinition { source_table: "people".into(), + source_namespace: Vec::new(), projections: vec![ ViewProjection { output: "name".into(), @@ -1199,9 +1686,86 @@ mod tests { .data_type(), &DataType::UInt64 ); + assert_eq!(view.table().count_rows(None).await.unwrap(), 3); + } + + #[tokio::test] + async fn test_with_no_data_skips_initial_refresh() { + let conn = people_db().await; + let view = conn + .create_materialized_view("empty", "people") + .with_no_data(true) + .execute() + .await + .unwrap(); assert_eq!(view.table().count_rows(None).await.unwrap(), 0); } + #[tokio::test] + async fn test_create_and_refresh_async_jobs() { + let conn = people_db().await; + let create_job = conn + .create_materialized_view("async_view", "people") + .with_no_data(true) + .execute_async() + .await + .unwrap(); + assert!(create_job.id().is_none()); + create_job.wait().await.unwrap(); + + let view = conn.open_materialized_view("async_view").await.unwrap(); + assert_eq!(view.table().count_rows(None).await.unwrap(), 0); + + let refresh_job = view.refresh().execute_async().await.unwrap(); + assert!(refresh_job.id().is_none()); + let result = refresh_job.wait().await.unwrap(); + assert_eq!(result.mode, RefreshMode::Rebuild); + assert_eq!(result.rows_written, 3); + assert_eq!(view.table().count_rows(None).await.unwrap(), 3); + + let drop_job = conn + .drop_materialized_view_async("async_view", &[]) + .await + .unwrap(); + assert!(drop_job.id().is_none()); + drop_job.wait().await.unwrap(); + assert!(conn.open_table("async_view").execute().await.is_err()); + } + + #[tokio::test] + async fn test_drop_materialized_view_rejects_plain_tables() { + let conn = people_db().await; + let error = conn + .drop_materialized_view("people", &[]) + .await + .unwrap_err(); + assert!(matches!(error, Error::NotAMaterializedView { .. })); + + conn.create_materialized_view("drop_me", "people") + .with_no_data(true) + .execute() + .await + .unwrap(); + conn.drop_materialized_view("drop_me", &[]).await.unwrap(); + assert!(conn.open_table("drop_me").execute().await.is_err()); + } + + #[tokio::test] + async fn test_remote_query_quotes_resource_identifiers() { + let conn = people_db().await; + let query = conn + .create_materialized_view("unused", "odd\"source") + .source_namespace(vec!["raw data".into()]) + .select([("double\"age", "age * 2")]) + .only_if("age >= 18") + .limit(10) + .query(); + assert_eq!( + query, + "SELECT age * 2 AS \"double\"\"age\" FROM \"raw data\".\"odd\"\"source\" WHERE age >= 18 LIMIT 10" + ); + } + /// No projection selects every source column, expanded now: the schema /// captured at creation is the definition. #[tokio::test] @@ -1349,14 +1913,9 @@ mod tests { .unwrap(); let views = conn.list_materialized_views().await.unwrap(); - assert_eq!( - views.iter().map(|v| v.name.as_str()).collect::>(), - vec!["adults"] - ); - let MaterializedViewKind::Select(definition) = &views[0].kind else { - panic!("expected a select view"); - }; - assert_eq!(definition.filter.as_deref(), Some("age >= 18")); + assert_eq!(views, vec!["adults"]); + let view = conn.open_materialized_view("adults").await.unwrap(); + assert_eq!(view.definition().filter.as_deref(), Some("age >= 18")); } /// The creation option outranks a connection configured to create @@ -1439,7 +1998,7 @@ mod tests { /// A newer-kind view must not disappear from the listing. #[tokio::test] - async fn test_unrecognized_kind_is_listed_with_its_kind() { + async fn test_unrecognized_kind_is_listed_by_name() { let conn = people_db().await; conn.create_materialized_view("v", "people") .execute() @@ -1457,36 +2016,7 @@ mod tests { .unwrap(); let views = conn.list_materialized_views().await.unwrap(); - assert_eq!(views.len(), 1); - assert_eq!(views[0].name, "v"); - assert_eq!( - views[0].kind, - MaterializedViewKind::Unrecognized { - kind: "join".into() - } - ); - } - - /// Remote connections are refused before any request is made. - #[cfg(feature = "remote")] - #[tokio::test] - async fn test_remote_connection_is_refused_up_front() { - let conn = connect("db://nowhere") - .api_key("sk_test") - .region("us-east-1") - .execute() - .await - .unwrap(); - let err = conn - .create_materialized_view("v", "src") - .execute() - .await - .unwrap_err(); - assert!(matches!(err, Error::NotSupported { .. })); - let err = conn.open_materialized_view("v").await.unwrap_err(); - assert!(matches!(err, Error::NotSupported { .. })); - let err = conn.list_materialized_views().await.unwrap_err(); - assert!(matches!(err, Error::NotSupported { .. })); + assert_eq!(views, vec!["v"]); } /// A definition must evaluate identically across refreshes; anything @@ -1593,7 +2123,16 @@ mod tests { /// declaration buried in a struct child binds as hard as one on top. #[tokio::test] async fn test_nested_projection_metadata_and_declarations() { - let conn = connect("memory://").execute().await.unwrap(); + // The schema below carries the legacy v1 blob marker, which Lance only + // allows writing at file version <= 2.1. + let conn = connect("memory://") + .storage_options([( + crate::database::listing::OPT_NEW_TABLE_STORAGE_VERSION, + "2.1", + )]) + .execute() + .await + .unwrap(); let payload = crate::blob("payload", true).with_metadata(HashMap::from([ ("lance-encoding:blob".to_string(), "true".to_string()), ( @@ -2021,7 +2560,7 @@ mod tests { ("id".to_string(), "id".to_string()), ("double".to_string(), "value * 2".to_string()), ]; - let prepared = prepare_declaration(&source, &projections, Some("value > 0"), None) + let prepared = prepare_declaration(&source, Some(&projections), Some("value > 0"), None) .await .unwrap(); assert_eq!(prepared.definition().source_table, "src"); @@ -2036,7 +2575,7 @@ mod tests { // external creation path cannot skip the check. conn.create_table("plain", batch).execute().await.unwrap(); let plain = conn.open_table("plain").execute().await.unwrap(); - let err = prepare_declaration(&plain, &[], None, None) + let err = prepare_declaration(&plain, None, None, None) .await .unwrap_err(); assert!(err.to_string().contains("stable row ids"), "{err}"); @@ -2058,7 +2597,7 @@ mod tests { .execute() .await .unwrap(); - let err = prepare_declaration(&masquerade, &[], None, None) + let err = prepare_declaration(&masquerade, None, None, None) .await .unwrap_err(); assert!( @@ -2079,37 +2618,805 @@ mod tests { .execute() .await .unwrap(); - let err = prepare_declaration(&custom, &[], None, None) + let err = prepare_declaration(&custom, None, None, None) .await .unwrap_err(); assert!(err.to_string().contains("custom_loc"), "{err}"); + } - // A namespaced source cannot be recorded in the definition: the - // bare name refresh resolves would reach a different table or none. - let namespaced = crate::table::NativeTable::create( - "memory://ns_src", - "ns_src", - vec!["ns".to_string()], - Box::new(arrow_array::RecordBatchIterator::new( - vec![], - std::sync::Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new( - "id", - arrow_schema::DataType::Int32, - true, - )])), - )) as Box, + /// A view declared over a namespaced source records that namespace, and + /// refresh resolves the source through it -- the coordinate round-trips. + #[tokio::test] + async fn a_namespaced_source_round_trips_through_refresh() { + use lance_namespace::models::CreateNamespaceRequest; + + let tmp = tempfile::tempdir().unwrap(); + let mut properties = std::collections::HashMap::new(); + properties.insert("root".to_string(), tmp.path().to_str().unwrap().to_string()); + let conn = crate::connect_namespace("dir", properties) + .execute() + .await + .unwrap(); + conn.create_namespace(CreateNamespaceRequest { + id: Some(vec!["ns".into()]), + ..Default::default() + }) + .await + .unwrap(); + + let batch = record_batch!( + ("name", Utf8, ["ada", "grace", "alan"]), + ("age", Int32, [36, 85, 41]) + ) + .unwrap(); + conn.create_table("people", batch) + .namespace(vec!["ns".to_string()]) + .write_options(stable_row_ids()) + .execute() + .await + .unwrap(); + + // A decoy of the same name at the root: resolving the source at the + // wrong namespace materializes one row here instead of three. + let decoy = record_batch!(("name", Utf8, ["mallory"]), ("age", Int32, [42])).unwrap(); + conn.create_table("people", decoy) + .write_options(stable_row_ids()) + .execute() + .await + .unwrap(); + + let view = conn + .create_materialized_view("adults", "people") + .with_no_data(true) + .namespace(vec!["ns".to_string()]) + .source_namespace(vec!["ns".to_string()]) + .select([("name", "name")]) + .only_if("age >= 18") + .execute() + .await + .unwrap(); + + assert_eq!(view.definition().source_table, "people"); + assert_eq!(view.definition().source_namespace, vec!["ns".to_string()]); + assert_eq!(view.table().namespace(), &["ns"]); + + // Refresh resolves the source at the recorded namespace, not at root. + let result = view.refresh().execute().await.unwrap(); + assert_eq!(result.rows_written, 3); + } + + /// A definition stored before namespaced sources existed carries no + /// namespace key and must read as the root namespace. + #[test] + fn a_definition_without_a_namespace_reads_as_root() { + let stored = + r#"{"source_table":"people","projections":[{"output":"name","expression":"name"}]}"#; + let definition: MaterializedViewDefinition = serde_json::from_str(stored).unwrap(); + assert!(definition.source_namespace.is_empty()); + } + + fn definition(source_namespace: Vec) -> MaterializedViewDefinition { + MaterializedViewDefinition { + source_table: "people".to_string(), + source_namespace, + projections: vec![ViewProjection { + output: "name".to_string(), + expression: "name".to_string(), + }], + filter: None, + limit: None, + inputs: vec!["name".to_string()], + } + } + + /// A root definition keeps the pre-namespace `select` form byte-stably; + /// a namespaced one moves off `select`, which sends pre-namespace readers + /// to the `Unrecognized` refusal instead of a root resolve. + #[test] + fn a_namespaced_definition_is_refused_by_the_pre_namespace_reader() { + let root = definition_to_metadata(&definition(Vec::new())).unwrap(); + let root: serde_json::Value = serde_json::from_str(&root).unwrap(); + assert_eq!(root["kind"], "select"); + assert!( + root.get("source_namespace").is_none(), + "a root definition must not grow new keys: {root}" + ); + + let stored = definition_to_metadata(&definition(vec!["ns".to_string()])).unwrap(); + let value: serde_json::Value = serde_json::from_str(&stored).unwrap(); + // The pre-namespace discriminator is `kind == "select"`; anything + // else lands in its Unrecognized refusal rather than in a root open. + assert_eq!(value["kind"], "namespaced_select"); + + // The current reader round-trips the coordinate. + let metadata = HashMap::from([(DEFINITION_META_KEY.to_string(), stored)]); + match materialized_view_kind(&metadata).unwrap() { + Some(MaterializedViewKind::Select(read)) => { + assert_eq!(read.source_namespace, vec!["ns".to_string()]) + } + other => panic!("expected the namespaced select form, got {other:?}"), + } + } + + /// A kind that disagrees with its namespace is an error, not a view: + /// under `select` it is the shape old readers would resolve at the root. + #[test] + fn a_kind_namespace_mismatch_is_refused() { + for (kind, namespace) in [ + (SELECT_KIND, vec!["ns".to_string()]), + (NAMESPACED_SELECT_KIND, Vec::new()), + ] { + let mut value = serde_json::to_value(definition(namespace)).unwrap(); + value["kind"] = serde_json::Value::String(kind.to_string()); + let metadata = HashMap::from([(DEFINITION_META_KEY.to_string(), value.to_string())]); + let err = materialized_view_kind(&metadata).unwrap_err(); + assert!( + err.to_string() + .contains("does not match its source namespace"), + "kind '{kind}': {err}" + ); + } + } + + /// A binding as the server records it: one Utf8 input over `input` + /// bound to a nullable parameter, one Int32 output named `output`, with + /// the exact schemas the durable contract requires. + pub fn test_binding(binding_id: &str, input: &str, output: &str) -> FunctionBinding { + let input_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ + ArrowField::new("text", DataType::Utf8, true), + ])) + .unwrap(); + let output_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ + ArrowField::new(output, DataType::Int32, true), + ])) + .unwrap(); + let input_type = input_schema.fields[0].r#type.r#type.clone(); + let output_type = output_schema.fields[0].r#type.r#type.clone(); + FunctionBinding::from_json( + &serde_json::json!({ + "binding_id": binding_id, + "function": {"name": "embed", "version": "1", "object_id": "fixture", "location": "memory:///fixture", + "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, + "inputs": [{ + "parameter": "text", "field_id": -1, "field_path": input, + "arrow_type": input_type, "nullable": true, + }], + "outputs": [{ + "result_field": "$value", "output_name": output, "output_field_id": -1, + "output_ordinal": 0, "arrow_type": output_type, "nullable": false, + }], + "input_schema": serde_json::to_value(input_schema).unwrap(), + "output_schema": serde_json::to_value(output_schema).unwrap(), + }) + .to_string(), + ) + .unwrap() + } + + /// A computed column as the server declares it on a table: bound to a + /// registered Function. + pub fn computed_field(name: &str, binding_id: &str, input: &str) -> ArrowField { + ArrowField::new(name, DataType::Int32, true).with_metadata( + crate::table::computed_columns::function_computed_column_metadata( + binding_id, + 0, + &[input.to_string()], + ), + ) + } + + pub async fn people(conn: &Connection) -> Table { + let batch = + record_batch!(("id", Int32, [1, 2, 3]), ("name", Utf8, ["a", "b", "c"])).unwrap(); + conn.create_table("people", batch) + .write_options(stable_row_ids()) + .execute() + .await + .unwrap() + } + + /// `people` with both columns non-nullable, for nullability cases. + pub async fn strict_people(conn: &Connection) -> Table { + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("name", DataType::Utf8, false), + ])); + let batch = arrow_array::RecordBatch::try_new( + schema, + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1, 2, 3])), + Arc::new(arrow_array::StringArray::from(vec!["a", "b", "c"])), + ], + ) + .unwrap(); + conn.create_table("people", batch) + .write_options(stable_row_ids()) + .execute() + .await + .unwrap() + } + + async fn prepared_people(conn: &Connection) -> PreparedDeclaration { + let source = people(conn).await; + prepare_declaration( + &source, + Some(&[ + ("id".to_string(), "id".to_string()), + ("name".to_string(), "name".to_string()), + ]), None, None, + ) + .await + .unwrap() + } + + #[tokio::test] + async fn a_computed_column_is_declared_null_with_its_binding() { + let conn = connect("memory://").execute().await.unwrap(); + let view = prepared_people(&conn) + .await + .with_computed_columns( + vec![(2, computed_field("emb", "fb_1", "name"))], + &[test_binding("fb_1", "name", "emb")], + ) + .unwrap() + .create("v") + .await + .unwrap(); + + let schema = view.table().schema().await.unwrap(); + let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect(); + assert_eq!(names, ["id", "name", "emb", SOURCE_ROW_ID_COLUMN]); + let declared: Vec = computed_columns(&schema) + .into_iter() + .map(|c| c.name) + .collect(); + assert_eq!(declared, ["emb"]); + let bindings = crate::table::computed_columns::function_bindings(&schema).unwrap(); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].binding_id(), "fb_1"); + // The stored definition is the plain select it always was. + let stored: serde_json::Value = + serde_json::from_str(&schema.metadata()[DEFINITION_META_KEY]).unwrap(); + assert_eq!(stored["kind"], SELECT_KIND); + assert_eq!(view.definition().projections.len(), 2); + assert_eq!(view.table().count_rows(None).await.unwrap(), 0); + assert_eq!(conn.open_materialized_view("v").await.unwrap().name(), "v"); + } + + #[tokio::test] + async fn computed_column_declarations_are_validated() { + let conn = connect("memory://").execute().await.unwrap(); + let prepared = prepared_people(&conn).await; + let binding = test_binding("fb_1", "name", "emb"); + let fails = |prepared: PreparedDeclaration, + columns: Vec<(usize, ArrowField)>, + bindings: &[FunctionBinding]| { + prepared + .with_computed_columns(columns, bindings) + .err() + .map(|e| e.to_string()) + .expect("the declaration should be refused") + }; + let emb = |binding_id: &str| computed_field("emb", binding_id, "name"); + + let err = fails( + prepared.clone(), + vec![(2, emb("fb_1").with_nullable(false))], + std::slice::from_ref(&binding), + ); + assert!(err.contains("must be nullable"), "{err}"); + + let plain = ArrowField::new("emb", DataType::Int32, true); + let err = fails( + prepared.clone(), + vec![(2, plain)], + std::slice::from_ref(&binding), + ); + assert!( + err.contains("does not carry a computed-column declaration"), + "{err}" + ); + + // The rest is the computed-column contract: a binding the field does + // not name, an output the binding does not map to this field, an + // input the view does not hold. + let err = fails( + prepared.clone(), + vec![(2, emb("fb_other"))], + std::slice::from_ref(&binding), + ); + assert!(err.contains("does not match binding 'fb_1'"), "{err}"); + let err = fails( + prepared.clone(), + vec![(2, emb("fb_1"))], + &[test_binding("fb_1", "name", "different_output")], + ); + assert!(err.contains("different_output"), "{err}"); + let err = fails( + prepared.clone(), + vec![(2, emb("fb_1"))], + &[test_binding("fb_1", "bio", "emb")], + ); + assert!(err.contains("'bio'"), "{err}"); + + let err = fails( + prepared.clone(), + vec![(2, computed_field("name", "fb_1", "name"))], + &[test_binding("fb_1", "name", "name")], + ); + assert!(err.contains("already exists"), "{err}"); + let err = fails( + prepared.clone(), + vec![(2, computed_field(SOURCE_ROW_ID_COLUMN, "fb_1", "name"))], + &[test_binding("fb_1", "name", SOURCE_ROW_ID_COLUMN)], + ); + assert!(err.contains("reserved"), "{err}"); + let err = fails( + prepared.clone(), + vec![(7, emb("fb_1"))], + std::slice::from_ref(&binding), + ); + assert!( + err.contains("placed at 7, past the view's 2 columns"), + "{err}" + ); + let err = fails(prepared, Vec::new(), std::slice::from_ref(&binding)); + assert!(err.contains("at least one computed column"), "{err}"); + } + + /// A source column a computed column reads without the view projecting + /// it becomes an internal projection before the provenance column, with + /// the source's nullability; a projected column is read from its + /// projection. + #[tokio::test] + async fn an_unprojected_input_becomes_an_internal_projection() { + let conn = connect("memory://").execute().await.unwrap(); + let source = strict_people(&conn).await; + let mut prepared = prepare_declaration( + &source, + Some(&[("key".to_string(), "id".to_string())]), None, None, - std::collections::HashSet::new(), ) .await .unwrap(); - let namespaced = Table::new(std::sync::Arc::new(namespaced), conn.database().clone()); - let err = prepare_declaration(&namespaced, &[], None, None) + assert_eq!(prepared.input_column("id").unwrap(), "key"); + assert_eq!(prepared.input_column("name").unwrap(), "__input_name"); + assert_eq!(prepared.input_column("name").unwrap(), "__input_name"); + let err = prepared.input_column("missing").unwrap_err().to_string(); + assert!(err.contains("no column 'missing'"), "{err}"); + + let view = prepared + .with_computed_columns( + vec![(1, computed_field("emb", "fb_1", "__input_name"))], + &[test_binding("fb_1", "__input_name", "emb")], + ) + .unwrap() + .create("v") .await - .unwrap_err(); - assert!(err.to_string().contains("namespaced source"), "{err}"); + .unwrap(); + let schema = view.table().schema().await.unwrap(); + let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect(); + assert_eq!(names, ["key", "emb", "__input_name", SOURCE_ROW_ID_COLUMN]); + let input = schema.field_with_name("__input_name").unwrap(); + assert_eq!(input.data_type(), &DataType::Utf8); + assert!( + !input.is_nullable(), + "the copy keeps the source's nullability" + ); + assert!(!schema.field_with_name("key").unwrap().is_nullable()); + let projections: Vec<(&str, &str)> = view + .definition() + .projections + .iter() + .map(|p| (p.output.as_str(), p.expression.as_str())) + .collect(); + assert_eq!(projections, [("key", "id"), ("__input_name", "`name`")]); + assert_eq!(view.definition().inputs, ["id", "name"]); + } + + /// Two outputs of one binding land at consecutive positions: each + /// insertion widens the range the next may take. + #[tokio::test] + async fn sibling_computed_columns_take_consecutive_positions() { + let conn = connect("memory://").execute().await.unwrap(); + let source = people(&conn).await; + let prepared = prepare_declaration( + &source, + Some(&[("id".to_string(), "id".to_string())]), + None, + None, + ) + .await + .unwrap(); + let metadata = |ordinal: u32| { + crate::table::computed_columns::function_computed_column_metadata( + "fb_pair", + ordinal, + &["id".to_string()], + ) + }; + let left = ArrowField::new("left", DataType::Int32, true).with_metadata(metadata(0)); + let right = ArrowField::new("right", DataType::Int32, true).with_metadata(metadata(1)); + let input_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ + ArrowField::new("value", DataType::Int32, true), + ])) + .unwrap(); + let output_schema = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ + ArrowField::new("left", DataType::Int32, true), + ArrowField::new("right", DataType::Int32, true), + ])) + .unwrap(); + let int = input_schema.fields[0].r#type.r#type.clone(); + let binding = FunctionBinding::from_json( + &serde_json::json!({ + "binding_id": "fb_pair", + "function": {"name": "pair", "version": "1", "object_id": "fixture", "location": "memory:///fixture", + "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, + "inputs": [{"parameter": "value", "field_id": -1, "field_path": "id", + "arrow_type": int, "nullable": true}], + "outputs": [ + {"result_field": "left", "output_name": "left", "output_field_id": -1, + "output_ordinal": 0, "arrow_type": int, "nullable": false}, + {"result_field": "right", "output_name": "right", "output_field_id": -1, + "output_ordinal": 1, "arrow_type": int, "nullable": false}, + ], + "input_schema": serde_json::to_value(input_schema).unwrap(), + "output_schema": serde_json::to_value(output_schema).unwrap(), + }) + .to_string(), + ) + .unwrap(); + let view = prepared + .with_computed_columns(vec![(1, left), (2, right)], &[binding]) + .unwrap() + .create("v") + .await + .unwrap(); + let names: Vec = view + .table() + .schema() + .await + .unwrap() + .fields() + .iter() + .map(|f| f.name().clone()) + .collect(); + assert_eq!(names, ["id", "left", "right", SOURCE_ROW_ID_COLUMN]); + } + + /// A SQL declaration as `add_columns().computed()` records it. + pub fn sql_field( + name: &str, + data_type: DataType, + expression: &str, + inputs: &str, + ) -> ArrowField { + use crate::table::computed_columns::{ + COMPUTED_COLUMN_META_KEY, EXPRESSION_META_KEY, INPUTS_META_KEY, KIND_META_KEY, SQL_KIND, + }; + ArrowField::new(name, data_type, true).with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + (EXPRESSION_META_KEY.to_string(), expression.to_string()), + (INPUTS_META_KEY.to_string(), inputs.to_string()), + ])) + } + + /// A SQL declaration is re-planned at admission: it must parse against + /// the view, yield the declared type, and read the inputs it declares. + #[tokio::test] + async fn a_sql_declaration_is_planned_at_admission() { + let conn = connect("memory://").execute().await.unwrap(); + let fails = |prepared: PreparedDeclaration, field: ArrowField| { + prepared + .with_computed_columns(vec![(1, field)], &[]) + .err() + .map(|e| e.to_string()) + .expect("the declaration should be refused") + }; + let prepared = prepared_people(&conn).await; + let err = fails( + prepared.clone(), + sql_field("bad", DataType::Int32, "missing + 1", r#"["missing"]"#), + ); + assert!(err.contains("missing"), "{err}"); + let err = fails( + prepared.clone(), + sql_field("wide", DataType::Int64, "id + 1", r#"["id"]"#), + ); + assert!( + err.contains("declared as Int64 but its expression yields Int32"), + "{err}" + ); + let err = fails( + prepared.clone(), + sql_field("lying", DataType::Int32, "id + 1", r#"["name"]"#), + ); + assert!(err.contains("declares inputs"), "{err}"); + + let view = prepared + .with_computed_columns( + vec![(1, sql_field("next", DataType::Int32, "id + 1", r#"["id"]"#))], + &[], + ) + .unwrap() + .create("v") + .await + .unwrap(); + let names: Vec = view + .table() + .schema() + .await + .unwrap() + .fields() + .iter() + .map(|f| f.name().clone()) + .collect(); + assert_eq!(names, ["id", "next", "name", SOURCE_ROW_ID_COLUMN]); + } + + /// Creation persists a declaration only when it re-plans and the data + /// carries no values for it, whichever door created the table. + #[tokio::test] + async fn a_created_table_cannot_carry_computed_values() { + let conn = connect("memory://").execute().await.unwrap(); + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("x", DataType::Int32, false), + sql_field("forged", DataType::Int32, "x + 1", r#"["x"]"#), + ])); + let filled = arrow_array::RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1])), + Arc::new(arrow_array::Int32Array::from(vec![999])), + ], + ) + .unwrap(); + let err = conn + .create_table("forged", filled) + .execute() + .await + .unwrap_err() + .to_string(); + assert!(err.contains("cannot be written directly"), "{err}"); + assert!( + !conn + .table_names() + .execute() + .await + .unwrap() + .contains(&"forged".to_string()) + ); + + let unfilled = arrow_array::RecordBatch::try_new( + schema, + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1])), + Arc::new(arrow_array::Int32Array::new_null(1)), + ], + ) + .unwrap(); + let table = conn + .create_table("declared", unfilled) + .execute() + .await + .unwrap(); + assert_eq!(table.refresh_column("forged").await.unwrap().rows_filled, 1); + + // A declaration with only its marker is broken, not absent. + let half = ArrowField::new("half", DataType::Int32, true).with_metadata(HashMap::from([( + crate::table::computed_columns::COMPUTED_COLUMN_META_KEY.to_string(), + "true".to_string(), + )])); + let batch = arrow_array::RecordBatch::try_new( + Arc::new(ArrowSchema::new(vec![ + ArrowField::new("x", DataType::Int32, false), + half, + ])), + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1])), + Arc::new(arrow_array::Int32Array::from(vec![999])), + ], + ) + .unwrap(); + let err = conn + .create_table("half", batch) + .execute() + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("incomplete computed-column declaration"), + "{err}" + ); + + let bogus = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("x", DataType::Int32, false), + sql_field("bad", DataType::Int32, "missing + 1", r#"["missing"]"#), + ])); + let batch = arrow_array::RecordBatch::new_empty(bogus); + let err = conn + .create_table("bogus", batch) + .execute() + .await + .unwrap_err() + .to_string(); + assert!(err.contains("missing"), "{err}"); + } + + /// The internal-input prefix is reserved for the declaration, like the + /// provenance column, so an alias cannot masquerade as an internal input. + #[tokio::test] + async fn the_internal_input_prefix_is_reserved() { + let conn = connect("memory://").execute().await.unwrap(); + let source = people(&conn).await; + let err = prepare_declaration( + &source, + Some(&[("__input_x".to_string(), "name".to_string())]), + None, + None, + ) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("'__input_x' is reserved"), "{err}"); + } + + /// A computed column may not read another, through any path: a + /// Function bound to a child of a computed struct is refused like a SQL + /// declaration over it. + #[tokio::test] + async fn a_computed_column_cannot_read_a_computed_root() { + let conn = connect("memory://").execute().await.unwrap(); + let prepared = prepared_people(&conn).await; + let payload = sql_field( + "payload", + DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), + "named_struct('value', name)", + r#"["name"]"#, + ); + let err = prepared + .with_computed_columns( + vec![ + (2, payload), + (3, computed_field("emb", "fb_dependent", "payload.value")), + ], + &[test_binding("fb_dependent", "payload.value", "emb")], + ) + .err() + .map(|e| e.to_string()) + .expect("a computed root as a Function input should be refused"); + assert!(err.contains("reads computed column 'payload'"), "{err}"); + } + + /// The root check uses the canonical path parser: a quoted top-level + /// name containing a dot is one root, not two segments. + #[tokio::test] + async fn a_quoted_computed_root_is_still_refused() { + let conn = connect("memory://").execute().await.unwrap(); + let prepared = prepared_people(&conn).await; + let payload = sql_field( + "payload.dot", + DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), + "named_struct('value', name)", + r#"["name"]"#, + ); + let input = "`payload.dot`.value"; + let err = prepared + .with_computed_columns( + vec![ + (2, payload), + (3, computed_field("emb", "fb_dependent", input)), + ], + &[test_binding("fb_dependent", input, "emb")], + ) + .err() + .map(|e| e.to_string()) + .expect("a quoted computed root should be refused"); + assert!(err.contains("reads computed column 'payload.dot'"), "{err}"); + } + + /// Namespace-backed creation admits declarations by the same rule, and + /// refuses before the namespace records the table. + #[tokio::test] + async fn a_namespace_created_table_cannot_carry_computed_values() { + let tmp = tempfile::tempdir().unwrap(); + let mut properties = std::collections::HashMap::new(); + properties.insert("root".to_string(), tmp.path().to_str().unwrap().to_string()); + let conn = crate::connect_namespace("dir", properties) + .execute() + .await + .unwrap(); + let half = ArrowField::new("half", DataType::Int32, true).with_metadata(HashMap::from([( + crate::table::computed_columns::COMPUTED_COLUMN_META_KEY.to_string(), + "true".to_string(), + )])); + let batch = arrow_array::RecordBatch::try_new( + Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + half, + ])), + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1])), + Arc::new(arrow_array::Int32Array::from(vec![999])), + ], + ) + .unwrap(); + let err = conn + .create_table("malformed", batch) + .execute() + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("incomplete computed-column declaration"), + "{err}" + ); + assert!(conn.table_names().execute().await.unwrap().is_empty()); + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + sql_field("next", DataType::Int32, "id + 1", r#"["id"]"#), + ])); + let filled = arrow_array::RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1])), + Arc::new(arrow_array::Int32Array::from(vec![999])), + ], + ) + .unwrap(); + let err = conn + .create_table("forged", filled) + .execute() + .await + .unwrap_err() + .to_string(); + assert!(err.contains("cannot be written directly"), "{err}"); + + let unfilled = arrow_array::RecordBatch::try_new( + schema, + vec![ + Arc::new(arrow_array::Int32Array::from(vec![1])), + Arc::new(arrow_array::Int32Array::new_null(1)), + ], + ) + .unwrap(); + let table = conn + .create_table("declared", unfilled) + .execute() + .await + .unwrap(); + assert_eq!(table.refresh_column("next").await.unwrap().rows_filled, 1); + } + + /// A projected column keeps its nullability; a computed value is + /// nullable. + #[tokio::test] + async fn an_identity_projection_keeps_source_nullability() { + let conn = connect("memory://").execute().await.unwrap(); + let source = strict_people(&conn).await; + let prepared = prepare_declaration( + &source, + Some(&[ + ("id".to_string(), "id".to_string()), + ("n".to_string(), "name".to_string()), + ("next".to_string(), "id + 1".to_string()), + ]), + None, + None, + ) + .await + .unwrap(); + let nullable: Vec = prepared + .schema() + .fields() + .iter() + .map(|f| f.is_nullable()) + .collect(); + assert_eq!(nullable, [false, false, true, false]); } } diff --git a/rust/lancedb/src/materialized_view/refresh.rs b/rust/lancedb/src/materialized_view/refresh.rs index b967e81f8..62b3db22f 100644 --- a/rust/lancedb/src/materialized_view/refresh.rs +++ b/rust/lancedb/src/materialized_view/refresh.rs @@ -24,8 +24,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use arrow_array::cast::AsArray; use arrow_array::types::UInt64Type; -use arrow_array::{RecordBatch, UInt64Array}; -use arrow_schema::{Schema as ArrowSchema, SchemaRef}; +use arrow_array::{RecordBatch, UInt64Array, new_null_array}; +use arrow_schema::{FieldRef, Schema as ArrowSchema, SchemaRef}; use datafusion::common::ScalarValue; use datafusion::error::DataFusionError; use datafusion::physical_plan::SendableRecordBatchStream; @@ -34,7 +34,7 @@ use datafusion::prelude::{col, lit}; use futures::{StreamExt, TryStreamExt}; use lance::Dataset; use lance::dataset::mem_wal::DatasetMemWalExt; -use lance::dataset::transaction::{Operation, Transaction}; +use lance::dataset::transaction::{Operation, Transaction, UpdateMode}; use lance::dataset::write::delete::DeleteBuilder; use lance::dataset::write::merge_insert::inserted_rows::{ KeyExistenceFilter, KeyExistenceFilterBuilder, KeyValue, @@ -51,6 +51,9 @@ use super::{ definition_to_metadata, }; use crate::database::OpenTableRequest; +use crate::table::computed_columns::{ + computed_column_from_field, computed_columns, ensure_declarations_are_planned, +}; use crate::table::{NativeTable, NativeTableExt, Table}; use crate::{Error, Result}; @@ -167,29 +170,52 @@ pub(crate) async fn execute_refresh( .map(|p| (p.output.clone(), p.expression.clone())) .collect(); validate_inputs(&source_ds, definition)?; - let (replanned, mut planned_fields, _renames) = super::plan( + let (replanned, planned_fields, _renames) = super::plan( source_schema, &definition.source_table, - &projections, + &definition.source_namespace, + Some(&projections), definition.filter.as_deref(), definition.limit, )?; + let mut planned_fields = planned_fields; planned_fields.push(arrow_schema::Field::new( SOURCE_ROW_ID_COLUMN, arrow_schema::DataType::UInt64, false, )); + // A computed column is not planned from the source: refresh writes it + // NULL and its declaration's owner fills it. Its declaration must still + // be complete, and it must be able to hold NULL. let physical = ArrowSchema::from(view_ds.schema()); - let planned_shape: Vec<_> = planned_fields - .iter() - .map(|f| (f.name().clone(), f.data_type().clone(), f.is_nullable())) - .collect(); - let physical_shape: Vec<_> = physical + let mut computed = computed_columns(&physical).into_iter().map(|c| c.name); + if let Some(name) = computed.by_ref().find(|name| { + physical + .field_with_name(name) + .is_ok_and(|f| !f.is_nullable()) + }) { + return Err(Error::Schema { + message: format!( + "computed column '{name}' of view '{}' cannot hold NULL; recreate the view", + view.name() + ), + }); + } + ensure_declarations_are_planned(&physical)?; + let physical_planned: Vec<&FieldRef> = physical .fields() .iter() - .map(|f| (f.name().clone(), f.data_type().clone(), f.is_nullable())) + .filter(|f| computed_column_from_field(f).is_none()) .collect(); - if planned_shape != physical_shape { + // A projected column that became nullable at the source still fits the + // view's nullable field; the reverse would not. + let matches = planned_fields.len() == physical_planned.len() + && planned_fields.iter().zip(&physical_planned).all(|(e, p)| { + e.name() == p.name() + && e.data_type() == p.data_type() + && (p.is_nullable() || !e.is_nullable()) + }); + if !matches { return Err(Error::Schema { message: format!( "the stored definition of view '{}' does not produce this \ @@ -228,11 +254,18 @@ pub(crate) async fn execute_refresh( .get(SOURCE_VERSION_TS_META_KEY) .and_then(|raw| raw.parse().ok()); // The watermark speaks only for the view state its refresh left behind; - // any other commit on the view since then is drift. - let view_intact = metadata + // any other commit on the view since then is drift, except a fill of its + // computed columns, which rewrites nothing refresh certifies. + let recorded_view_version = metadata .get(VIEW_VERSION_META_KEY) - .and_then(|raw| raw.parse::().ok()) - == Some(view_ds.version().version); + .and_then(|raw| raw.parse::().ok()); + let view_intact = match recorded_view_version { + Some(recorded) if recorded == view_ds.version().version => true, + Some(recorded) if recorded < view_ds.version().version => { + only_computed_rewrites_since(&view_ds, recorded).await? + } + _ => false, + }; if !full && watermark == Some(source_version) && view_intact && recorded_ts == Some(source_ts) { return Ok(RefreshMaterializedViewResult { @@ -590,7 +623,7 @@ async fn open_source(view: &Table, definition: &MaterializedViewDefinition) -> R let source = database .open_table(OpenTableRequest { name: definition.source_table.clone(), - namespace_path: Vec::new(), + namespace_path: definition.source_namespace.clone(), index_cache_size: None, lance_read_params: None, location: None, @@ -1089,6 +1122,83 @@ struct RowScope { limit: Option, } +/// Whether every commit on the view after `recorded` is a fill of its +/// computed columns: a column rewrite or data replacement touching only +/// those fields and neither adding nor removing rows, or the freshness +/// stamp a fill leaves on them. A version whose transaction cannot be read +/// is not proven, so it counts as drift. +async fn only_computed_rewrites_since(view_ds: &Dataset, recorded: u64) -> Result { + // A fill may write any field under a computed column, so the whole + // subtree counts, not only the root. + let physical = ArrowSchema::from(view_ds.schema()); + fn subtree(field: &lance_core::datatypes::Field, ids: &mut Vec) { + ids.push(field.id as u32); + for child in &field.children { + subtree(child, ids); + } + } + let mut computed_fields = Vec::new(); + for column in computed_columns(&physical) { + if let Some(field) = view_ds.schema().field(&column.name) { + subtree(field, &mut computed_fields); + } + } + if computed_fields.is_empty() { + return Ok(false); + } + for version in recorded + 1..=view_ds.version().version { + let Some(transaction) = view_ds.read_transaction_by_version(version).await? else { + return Ok(false); + }; + let fill = match &transaction.operation { + Operation::Update { + removed_fragment_ids, + new_fragments, + fields_modified, + update_mode: Some(UpdateMode::RewriteColumns), + .. + } => { + removed_fragment_ids.is_empty() + && new_fragments.is_empty() + && !fields_modified.is_empty() + && fields_modified + .iter() + .all(|field| computed_fields.contains(field)) + } + // What `refresh_column` commits for a SQL declaration. + Operation::DataReplacement { replacements } => { + !replacements.is_empty() + && replacements.iter().all(|group| { + !group.1.fields.is_empty() + && group + .1 + .fields + .iter() + .all(|field| computed_fields.contains(&(*field as u32))) + }) + } + // The stamp `refresh_column` writes after its fill (see + // `table::freshness`): field metadata on computed columns, no data. + Operation::UpdateConfig { + config_updates: None, + table_metadata_updates: None, + schema_metadata_updates: None, + field_metadata_updates, + } => { + !field_metadata_updates.is_empty() + && field_metadata_updates + .keys() + .all(|field| computed_fields.contains(&(*field as u32))) + } + _ => false, + }; + if !fill { + return Ok(false); + } + } + Ok(true) +} + async fn compute_stream( source: &Dataset, definition: &MaterializedViewDefinition, @@ -1157,6 +1267,10 @@ async fn compute_stream( let batch = batch.map_err(|e| DataFusionError::External(Box::new(e)))?; let mut columns = Vec::with_capacity(out_schema.fields().len()); for field in out_schema.fields() { + if computed_column_from_field(field).is_some() { + columns.push(new_null_array(field.data_type(), batch.num_rows())); + continue; + } let name = if field.name() == SOURCE_ROW_ID_COLUMN { ROW_ID } else { @@ -1543,6 +1657,7 @@ mod tests { async fn doubled_view(conn: &Connection) -> MaterializedView { conn.create_materialized_view("doubled", "src") + .with_no_data(true) .select([("x", "x"), ("twice", "x * 2")]) .execute() .await @@ -1610,6 +1725,7 @@ mod tests { let (conn, _) = db_with_source(vec![1, 20, 3, 40]).await; let view = conn .create_materialized_view("big", "src") + .with_no_data(true) .select([("x", "x")]) .only_if("x > 10") .execute() @@ -1635,6 +1751,7 @@ mod tests { .await .unwrap(); conn.create_materialized_view("democrats", "src") + .with_no_data(true) .select([("id", "id")]) .only_if(r#""PartyAbbrev" = 'D'"#) .execute() @@ -1670,6 +1787,7 @@ mod tests { .unwrap(); let view = conn .create_materialized_view("legacy_view", "legacy_src") + .with_no_data(true) .select([("id", "id")]) .only_if(r#""PartyAbbrev" = 'X'"#) .execute() @@ -1737,6 +1855,7 @@ mod tests { let (conn, source) = db_with_source(vec![1, 20]).await; let view = conn .create_materialized_view("big", "src") + .with_no_data(true) .select([("x", "x")]) .only_if("x > 10") .execute() @@ -1758,6 +1877,7 @@ mod tests { let (conn, source) = db_with_source(vec![20]).await; let view = conn .create_materialized_view("big", "src") + .with_no_data(true) .select([("x", "x")]) .only_if("x > 10") .execute() @@ -1815,6 +1935,7 @@ mod tests { .unwrap(); let view = conn .create_materialized_view("legacy_doubled", "legacy_src") + .with_no_data(true) .select([("x", "x"), ("twice", "x * 2")]) .execute() .await @@ -1893,6 +2014,7 @@ mod tests { let (conn, source) = db_with_source(vec![1, 2, 3]).await; let view = conn .create_materialized_view("drifting_view", "src") + .with_no_data(true) .select([("x", "x"), ("twice", "x * 2")]) .execute() .await @@ -1979,6 +2101,7 @@ mod tests { let (conn, source) = db_with_source(vec![1, 2, 3]).await; let view = conn .create_materialized_view("atomic_view", "src") + .with_no_data(true) .select([("x", "x"), ("twice", "x * 2")]) .execute() .await @@ -2081,6 +2204,7 @@ mod tests { let (conn, _) = db_with_source(vec![1, 2, 3]).await; let view = conn .create_materialized_view("raced_rebuild", "src") + .with_no_data(true) .select([("x", "x"), ("twice", "x * 2")]) .execute() .await @@ -2177,6 +2301,7 @@ mod tests { let (conn, source) = db_with_source(vec![1, 2, 3]).await; let view = conn .create_materialized_view("raced_incremental", "src") + .with_no_data(true) .select([("x", "x"), ("twice", "x * 2")]) .execute() .await @@ -2295,6 +2420,7 @@ mod tests { let (conn, source) = db_with_source(vec![1, 2, 3]).await; let view = conn .create_materialized_view("empty", "src") + .with_no_data(true) .select([("x", "x")]) .limit(0) .execute() @@ -2462,6 +2588,7 @@ mod tests { let (conn, source) = db_with_source(vec![1, 2]).await; let view = conn .create_materialized_view("capped", "src") + .with_no_data(true) .select([("x", "x")]) .limit(2) .execute() @@ -2494,6 +2621,7 @@ mod tests { let (conn, source) = db_with_source(vec![1, 2, 3]).await; let view = conn .create_materialized_view("capped", "src") + .with_no_data(true) .select([("x", "x")]) .limit(4) .execute() @@ -2563,6 +2691,7 @@ mod tests { let (conn, _) = db_with_source(vec![1, 2]).await; let view = conn .create_materialized_view("none", "src") + .with_no_data(true) .select([("x", "x")]) .only_if("x > 100") .execute() @@ -2606,6 +2735,7 @@ mod tests { let (conn, source) = db_with_source(vec![1]).await; let view = conn .create_materialized_view("v", "src") + .with_no_data(true) .select([("twice", "x * 2")]) .execute() .await @@ -2665,6 +2795,7 @@ mod tests { let second = conn .create_materialized_view("second", "doubled") + .with_no_data(true) .only_if("twice > 10") .execute() .await @@ -2767,7 +2898,7 @@ mod tests { let (conn, source) = db_with_source(vec![1]).await; let prepared = crate::materialized_view::prepare_declaration( &source, - &[("x".into(), "x".into()), ("twice".into(), "x * 2".into())], + Some(&[("x".into(), "x".into()), ("twice".into(), "x * 2".into())]), None, None, ) @@ -2919,6 +3050,7 @@ mod tests { let replacement = crate::materialized_view::MaterializedViewDefinition { source_table: "src".into(), + source_namespace: Vec::new(), projections: vec![ crate::materialized_view::ViewProjection { output: "x".into(), @@ -2958,6 +3090,7 @@ mod tests { let narrower = crate::materialized_view::MaterializedViewDefinition { source_table: "src".into(), + source_namespace: Vec::new(), projections: vec![crate::materialized_view::ViewProjection { output: "x".into(), expression: "x".into(), @@ -3030,6 +3163,7 @@ mod tests { let (conn, _) = db_with_source(vec![1, 2]).await; let view = conn .create_materialized_view("v", "src") + .with_no_data(true) .select([("double value", "x * 2")]) .execute() .await @@ -3083,6 +3217,7 @@ mod tests { // An active-LSM source is refused at create. let err = conn .create_materialized_view("v", "src") + .with_no_data(true) .execute() .await .unwrap_err(); @@ -3093,6 +3228,7 @@ mod tests { table.unset_lsm_write_spec().await.unwrap(); let view = conn .create_materialized_view("v", "src") + .with_no_data(true) .execute() .await .unwrap(); @@ -3129,4 +3265,464 @@ mod tests { let err = view.refresh().execute().await.unwrap_err(); assert!(err.to_string().contains("source table 'src'"), "{err}"); } + + /// A view with a computed column, declared over `people` and refreshed. + async fn refreshed_computed_view(conn: &Connection) -> MaterializedView { + use crate::materialized_view::tests::{computed_field, people, test_binding}; + let source = people(conn).await; + let view = crate::materialized_view::prepare_declaration( + &source, + Some(&[ + ("id".to_string(), "id".to_string()), + ("name".to_string(), "name".to_string()), + ]), + None, + None, + ) + .await + .unwrap() + .with_computed_columns( + vec![(2, computed_field("emb", "fb_1", "name"))], + &[test_binding("fb_1", "name", "emb")], + ) + .unwrap() + .create("v") + .await + .unwrap(); + let result = view.refresh().execute().await.unwrap(); + assert_eq!(result.mode, RefreshMode::Rebuild); + view + } + + async fn unfilled(view: &MaterializedView) -> usize { + view.table() + .count_rows(Some("emb IS NULL".to_string())) + .await + .unwrap() + } + + async fn append_people(conn: &Connection, ids: Vec, names: Vec<&str>) { + let batch = record_batch!(("id", Int32, ids), ("name", Utf8, names)).unwrap(); + conn.open_table("people") + .execute() + .await + .unwrap() + .add(batch) + .execute() + .await + .unwrap(); + } + + /// Commit the fill job's shape on the view: a column rewrite of + /// `fields`, touching no rows. The data is left as it is; what matters + /// here is how the next refresh classifies the commit. + async fn commit_column_rewrite(view: &MaterializedView, fields: &[&str]) { + let native = view.table().as_native().unwrap(); + native.dataset.reload().await.unwrap(); + let dataset = native.dataset.get().await.unwrap().as_ref().clone(); + let fields_modified = fields + .iter() + .map(|name| dataset.schema().field(name).unwrap().id as u32) + .collect(); + let updated_fragments = dataset + .get_fragments() + .iter() + .map(|fragment| fragment.metadata().clone()) + .collect(); + let operation = Operation::Update { + removed_fragment_ids: Vec::new(), + updated_fragments, + new_fragments: Vec::new(), + fields_modified, + compacted_sstables: Vec::new(), + fields_for_preserving_frag_bitmap: Vec::new(), + update_mode: Some(UpdateMode::RewriteColumns), + inserted_rows_filter: None, + updated_fragment_offsets: None, + }; + let read_version = dataset.version().version; + CommitBuilder::new(WriteDestination::Dataset(Arc::new(dataset))) + .execute(Transaction::new(read_version, operation, None)) + .await + .unwrap(); + } + + /// Refresh never computes a computed column: every row it writes, on a + /// rebuild, an append and a rewrite, carries NULL there, and the + /// declaration survives all three. + #[tokio::test] + async fn test_computed_columns_are_written_null_and_kept() { + let conn = connect("memory://").execute().await.unwrap(); + let view = refreshed_computed_view(&conn).await; + assert_eq!(unfilled(&view).await, 3); + + append_people(&conn, vec![4, 5], vec!["d", "e"]).await; + let result = view.refresh().execute().await.unwrap(); + assert_eq!(result.mode, RefreshMode::Incremental); + assert_eq!(unfilled(&view).await, 5); + + conn.open_table("people") + .execute() + .await + .unwrap() + .update() + .column("name", "'z'") + .only_if("id = 1") + .execute() + .await + .unwrap(); + view.refresh().execute().await.unwrap(); + assert_eq!(unfilled(&view).await, 5); + assert_eq!(read(view.table(), "id").await, vec![1, 2, 3, 4, 5]); + + let schema = view.table().schema().await.unwrap(); + assert!( + crate::table::computed_columns::function_bindings(&schema) + .unwrap() + .iter() + .any(|b| b.binding_id() == "fb_1"), + "the binding envelope was lost" + ); + assert!( + computed_column_from_field(schema.field_with_name("emb").unwrap()).is_some(), + "the declaration was lost" + ); + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::NoOp + ); + } + + /// Field metadata on `field` only, the commit shape of the freshness + /// stamp `refresh_column` leaves after its fill. + async fn commit_field_metadata(view: &MaterializedView, field: &str, key: &str) { + let native = view.table().as_native().unwrap(); + native.dataset.reload().await.unwrap(); + let mut dataset = native.dataset.get().await.unwrap().as_ref().clone(); + dataset + .update_field_metadata() + .update(field, [(key.to_string(), "{}".to_string())]) + .unwrap() + .await + .unwrap(); + } + + /// The stamp is metadata on the computed column and rewrites nothing + /// refresh certifies, so it is not drift; the same commit shape on a + /// projected column is, like any other write to it. + #[tokio::test] + async fn test_a_freshness_stamp_is_not_drift() { + let conn = connect("memory://").execute().await.unwrap(); + let view = refreshed_computed_view(&conn).await; + + commit_field_metadata( + &view, + "emb", + crate::table::computed_columns::SOURCE_SIGNATURE_META_KEY, + ) + .await; + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::NoOp + ); + + commit_field_metadata(&view, "id", "probe").await; + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::Rebuild + ); + } + + /// The fill job's commit rewrites only computed columns. It is the one + /// commit on a view that is not drift: the next refresh carries on from + /// its watermark instead of rebuilding, which would null what the fill + /// just wrote. + #[tokio::test] + async fn test_a_computed_column_fill_is_not_drift() { + let conn = connect("memory://").execute().await.unwrap(); + let view = refreshed_computed_view(&conn).await; + + commit_column_rewrite(&view, &["emb"]).await; + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::NoOp + ); + + commit_column_rewrite(&view, &["emb"]).await; + append_people(&conn, vec![4], vec!["d"]).await; + let result = view.refresh().execute().await.unwrap(); + assert_eq!(result.mode, RefreshMode::Incremental); + assert_eq!(result.rows_written, 1); + assert_eq!(read(view.table(), "id").await, vec![1, 2, 3, 4]); + } + + /// A column rewrite that reaches a projected column is drift like any + /// other write: refresh certifies those columns and must recompute them. + #[tokio::test] + async fn test_a_rewrite_of_a_projected_column_is_drift() { + let conn = connect("memory://").execute().await.unwrap(); + let view = refreshed_computed_view(&conn).await; + + commit_column_rewrite(&view, &["emb", "name"]).await; + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::Rebuild + ); + } + + /// The declaration contract is checked before any refresh mutation: a + /// missing binding envelope and a column that lost its declaration both + /// fail closed. + #[tokio::test] + async fn test_a_broken_declaration_is_refused_before_refresh() { + let conn = connect("memory://").execute().await.unwrap(); + let view = refreshed_computed_view(&conn).await; + let native = view.table().as_native().unwrap(); + let mut dataset = native.dataset.get().await.unwrap().as_ref().clone(); + dataset + .update_schema_metadata(vec![( + crate::table::computed_columns::FUNCTION_BINDINGS_META_KEY.to_string(), + None, + )]) + .await + .unwrap(); + let err = view.refresh().execute().await.unwrap_err().to_string(); + assert!(err.contains("references missing binding 'fb_1'"), "{err}"); + + let conn = connect("memory://").execute().await.unwrap(); + let view = refreshed_computed_view(&conn).await; + let native = view.table().as_native().unwrap(); + let mut dataset = native.dataset.get().await.unwrap().as_ref().clone(); + dataset + .replace_field_metadata(vec![( + dataset.schema().field("emb").unwrap().id as u32, + HashMap::new(), + )]) + .await + .unwrap(); + let err = view.refresh().execute().await.unwrap_err().to_string(); + assert!(err.contains("does not match binding 'fb_1'"), "{err}"); + } + + /// An input the view does not project is materialized on every refresh + /// path, before the provenance column, with the source's values. + #[tokio::test] + async fn test_internal_inputs_are_materialized_and_refreshed() { + use crate::materialized_view::tests::{computed_field, strict_people, test_binding}; + let conn = connect("memory://").execute().await.unwrap(); + let source = strict_people(&conn).await; + let mut prepared = crate::materialized_view::prepare_declaration( + &source, + Some(&[("id".to_string(), "id".to_string())]), + None, + None, + ) + .await + .unwrap(); + let input = prepared.input_column("name").unwrap(); + let view = prepared + .with_computed_columns( + vec![(1, computed_field("emb", "fb_1", &input))], + &[test_binding("fb_1", &input, "emb")], + ) + .unwrap() + .create("v") + .await + .unwrap(); + let names: Vec = view + .table() + .schema() + .await + .unwrap() + .fields() + .iter() + .map(|f| f.name().clone()) + .collect(); + assert_eq!(names, ["id", "emb", "__input_name", SOURCE_ROW_ID_COLUMN]); + + let unfilled_inputs = || async { + view.table() + .count_rows(Some("__input_name IS NULL".to_string())) + .await + .unwrap() + }; + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::Rebuild + ); + assert_eq!(view.table().count_rows(None).await.unwrap(), 3); + assert_eq!(unfilled_inputs().await, 0); + + let more = arrow_array::RecordBatch::try_new( + source.schema().await.unwrap(), + vec![ + Arc::new(Int32Array::from(vec![4])), + Arc::new(arrow_array::StringArray::from(vec!["d"])), + ], + ) + .unwrap(); + source.add(more).execute().await.unwrap(); + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::Incremental + ); + assert_eq!(unfilled_inputs().await, 0); + assert_eq!( + view.table() + .count_rows(Some("__input_name = 'd'".to_string())) + .await + .unwrap(), + 1 + ); + + source + .update() + .column("name", "'z'") + .only_if("id = 1") + .execute() + .await + .unwrap(); + view.refresh().execute().await.unwrap(); + assert_eq!( + view.table() + .count_rows(Some("__input_name = 'z'".to_string())) + .await + .unwrap(), + 1 + ); + assert_eq!( + unfilled(&view).await, + 4, + "rewritten and new rows are unfilled" + ); + } + + /// A SQL declaration is filled by `refresh_column` on the view, which + /// commits a data replacement and then its freshness stamp; the next + /// refresh continues from its watermark and keeps what the fill wrote, + /// and only rows the view added since come back unfilled. + #[tokio::test] + async fn test_a_sql_fill_is_not_drift() { + use crate::materialized_view::tests::{people, sql_field}; + let conn = connect("memory://").execute().await.unwrap(); + let source = people(&conn).await; + let view = crate::materialized_view::prepare_declaration( + &source, + Some(&[("id".to_string(), "id".to_string())]), + None, + None, + ) + .await + .unwrap() + .with_computed_columns( + vec![( + 1, + sql_field("next", arrow_schema::DataType::Int32, "id + 1", r#"["id"]"#), + )], + &[], + ) + .unwrap() + .create("v") + .await + .unwrap(); + let filled = || async { + view.table() + .count_rows(Some("next = id + 1".to_string())) + .await + .unwrap() + }; + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::Rebuild + ); + assert_eq!( + view.table() + .refresh_column("next") + .await + .unwrap() + .rows_filled, + 3 + ); + assert_eq!(filled().await, 3); + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::NoOp + ); + assert_eq!(filled().await, 3); + + append_people(&conn, vec![4], vec!["d"]).await; + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::Incremental + ); + assert_eq!(filled().await, 3); + assert_eq!( + view.table() + .refresh_column("next") + .await + .unwrap() + .rows_filled, + 1 + ); + assert_eq!(filled().await, 4); + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::NoOp + ); + } + + /// A fill of a nested computed column writes its child fields; that is + /// still a fill, not drift. + #[tokio::test] + async fn test_a_nested_sql_fill_is_not_drift() { + use crate::materialized_view::tests::{people, sql_field}; + let conn = connect("memory://").execute().await.unwrap(); + let source = people(&conn).await; + let payload = sql_field( + "payload", + arrow_schema::DataType::Struct( + vec![arrow_schema::Field::new( + "value", + arrow_schema::DataType::Utf8, + true, + )] + .into(), + ), + "named_struct('value', name)", + r#"["name"]"#, + ); + let view = crate::materialized_view::prepare_declaration( + &source, + Some(&[("name".to_string(), "name".to_string())]), + None, + None, + ) + .await + .unwrap() + .with_computed_columns(vec![(1, payload)], &[]) + .unwrap() + .create("v") + .await + .unwrap(); + view.refresh().execute().await.unwrap(); + assert_eq!( + view.table() + .refresh_column("payload") + .await + .unwrap() + .rows_filled, + 3 + ); + assert_eq!( + view.refresh().execute().await.unwrap().mode, + RefreshMode::NoOp + ); + assert_eq!( + view.table() + .count_rows(Some("payload.value = name".to_string())) + .await + .unwrap(), + 3 + ); + } } diff --git a/rust/lancedb/src/query.rs b/rust/lancedb/src/query.rs index cd346f42e..ab1bdfc4a 100644 --- a/rust/lancedb/src/query.rs +++ b/rust/lancedb/src/query.rs @@ -1,21 +1,37 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors +use std::collections::{HashMap, HashSet}; +use std::pin::Pin; use std::sync::Arc; use std::{future::Future, time::Duration}; use arrow::compute::concat_batches; -use arrow_array::{Array, Float16Array, Float32Array, Float64Array, RecordBatch, make_array}; +use arrow_array::{ + Array, Float16Array, Float32Array, Float64Array, RecordBatch, UInt64Array, + cast::AsArray, + make_array, + types::{Int64Type, UInt64Type}, +}; use arrow_schema::{DataType, SchemaRef}; +use datafusion_common::{DataFusionError, Result as DataFusionResult}; +use datafusion_execution::TaskContext; use datafusion_expr::{Expr, col, lit}; -use datafusion_physical_plan::ExecutionPlan; -use futures::{FutureExt, TryFutureExt, TryStreamExt, stream, try_join}; +use datafusion_physical_expr::{EquivalenceProperties, Partitioning}; +use datafusion_physical_plan::{ + DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties, + coalesce_partitions::CoalescePartitionsExec, + execution_plan::{Boundedness, EmissionType}, + limit::GlobalLimitExec, + stream::RecordBatchStreamAdapter, +}; +use futures::{FutureExt, StreamExt, TryFutureExt, TryStreamExt, stream, try_join}; use half::f16; /// Re-export Lance ColumnOrdering type for use in query ordering pub use lance::dataset::scanner::ColumnOrdering; use lance::dataset::{ROW_ID, scanner::DatasetRecordBatchStream}; use lance_arrow::RecordBatchExt; -use lance_datafusion::exec::execute_plan; +use lance_datafusion::exec::{execute_plan, format_plan as format_analyzed_plan}; use lance_index::scalar::FullTextSearchQuery; use lance_index::scalar::inverted::SCORE_COL; use lance_index::vector::DIST_COL; @@ -825,6 +841,14 @@ pub struct QueryRequest { /// Offset of the query. pub offset: Option, + /// Dataset offsets whose occurrence multiplicity must be restored after + /// executing the physical lookup represented by this request. + /// + /// This is client-side execution metadata used when a [`TakeQuery`] is + /// converted into a request. It is not sent to remote services. + #[doc(hidden)] + pub take_offsets: Option>, + /// Apply filter to the returned rows. pub filter: Option, @@ -893,6 +917,7 @@ impl Default for QueryRequest { Self { limit: None, offset: None, + take_offsets: None, filter: None, filter_error: None, full_text_search: None, @@ -1274,7 +1299,7 @@ impl VectorQuery { /// This can be useful when there is a narrow filter to allow these queries to /// spend more time searching and avoid potential false negatives. /// - /// Set to None to search all partitions, if needed, to satsify the limit + /// Set to None to search all partitions, if needed, to satisfy the limit pub fn maximum_nprobes(mut self, maximum_nprobes: Option) -> Result { if let Some(maximum_nprobes) = maximum_nprobes { if maximum_nprobes == 0 { @@ -1529,6 +1554,302 @@ impl HasQuery for VectorQuery { } } +fn take_occurrences(offsets: &[u64]) -> HashMap { + let mut occurrences = HashMap::with_capacity(offsets.len()); + for offset in offsets { + *occurrences.entry(*offset).or_insert(0) += 1; + } + occurrences +} + +fn restore_take_batch_with_occurrences( + batch: RecordBatch, + offsets: &[u64], + occurrences: &HashMap, + ordering_column: &str, + drop_ordering_column: bool, + preserve_order: bool, +) -> Result { + let actual_offsets = batch + .column_by_name(ordering_column) + .ok_or_else(|| Error::Schema { + message: format!( + "take query result did not include ordering column '{ordering_column}'" + ), + })?; + let actual_offsets = match actual_offsets.data_type() { + DataType::UInt64 => actual_offsets + .as_primitive::() + .values() + .to_vec(), + DataType::Int64 => actual_offsets + .as_primitive::() + .values() + .iter() + .map(|offset| { + u64::try_from(*offset).map_err(|_| Error::Schema { + message: format!( + "take query ordering column '{ordering_column}' contained a negative offset" + ), + }) + }) + .collect::>>()?, + data_type => { + return Err(Error::Schema { + message: format!( + "take query ordering column '{ordering_column}' had unsupported type {data_type}" + ), + }); + } + }; + + let mut desired_order = Vec::with_capacity(offsets.len()); + if preserve_order { + let ordering = actual_offsets + .iter() + .copied() + .enumerate() + .map(|(index, offset)| (offset, index as u64)) + .collect::>(); + // Missing offsets retain the filter-based behavior of returning no row. + desired_order.extend( + offsets + .iter() + .filter_map(|offset| ordering.get(offset).copied()), + ); + } else { + // Public take queries do not guarantee output order. Preserve the lookup's + // existing order and only restore the multiplicity of each matching row. + for (index, offset) in actual_offsets.iter().enumerate() { + if let Some(count) = occurrences.get(offset) { + desired_order.extend(std::iter::repeat_n(index as u64, *count)); + } + } + } + + let mut ordered_batch = if desired_order.len() == batch.num_rows() + && desired_order + .iter() + .enumerate() + .all(|(index, desired)| *desired == index as u64) + { + batch + } else { + arrow_select::take::take_record_batch(&batch, &UInt64Array::from(desired_order))? + }; + + if drop_ordering_column { + ordered_batch = ordered_batch.drop_column(ordering_column)?; + } + + Ok(ordered_batch) +} + +#[cfg(test)] +fn restore_take_batch( + batch: RecordBatch, + offsets: &[u64], + ordering_column: &str, + drop_ordering_column: bool, + preserve_order: bool, +) -> Result { + restore_take_batch_with_occurrences( + batch, + offsets, + &take_occurrences(offsets), + ordering_column, + drop_ordering_column, + preserve_order, + ) +} + +/// Restores the logical offset occurrence sequence above the physical lookup plan. +/// +/// The lookup plan returns each matching row at most once. For ordinary unordered +/// takes this operator expands each input batch incrementally and preserves the +/// lookup's partitioning. The explicitly ordered reader path collects one coalesced +/// input before restoring requested order. Pagination must remain above this operator +/// so it applies to occurrences. +#[derive(Debug)] +struct TakeRestoreExec { + input: Arc, + offsets: Vec, + occurrences: Arc>, + ordering_column: String, + drop_ordering_column: bool, + preserve_order: bool, + schema: SchemaRef, + properties: Arc, +} + +impl TakeRestoreExec { + fn try_new( + input: Arc, + offsets: Vec, + ordering_column: String, + drop_ordering_column: bool, + preserve_order: bool, + ) -> Result { + let schema = if drop_ordering_column { + RecordBatch::new_empty(input.schema()) + .drop_column(&ordering_column)? + .schema() + } else { + input.schema() + }; + let partition_count = if preserve_order { + 1 + } else { + input.output_partitioning().partition_count() + }; + let emission_type = if preserve_order { + EmissionType::Final + } else { + EmissionType::Incremental + }; + let properties = Arc::new(PlanProperties::new( + EquivalenceProperties::new(schema.clone()), + Partitioning::UnknownPartitioning(partition_count), + emission_type, + Boundedness::Bounded, + )); + + Ok(Self { + input, + occurrences: Arc::new(take_occurrences(&offsets)), + offsets, + ordering_column, + drop_ordering_column, + preserve_order, + schema, + properties, + }) + } +} + +impl DisplayAs for TakeRestoreExec { + fn fmt_as( + &self, + _display_type: DisplayFormatType, + formatter: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + write!( + formatter, + "TakeRestoreExec: occurrences={}", + self.offsets.len() + ) + } +} + +impl ExecutionPlan for TakeRestoreExec { + fn name(&self) -> &str { + "TakeRestoreExec" + } + + fn properties(&self) -> &Arc { + &self.properties + } + + fn children(&self) -> Vec<&Arc> { + vec![&self.input] + } + + fn maintains_input_order(&self) -> Vec { + vec![!self.preserve_order] + } + + fn benefits_from_input_partitioning(&self) -> Vec { + vec![false] + } + + fn with_new_children( + self: Arc, + children: Vec>, + ) -> DataFusionResult> { + if children.len() != 1 { + return Err(DataFusionError::Internal(format!( + "TakeRestoreExec expected one child, got {}", + children.len() + ))); + } + let child = children.into_iter().next().unwrap(); + let plan = Self::try_new( + child, + self.offsets.clone(), + self.ordering_column.clone(), + self.drop_ordering_column, + self.preserve_order, + ) + .map_err(|error| DataFusionError::External(Box::new(error)))?; + Ok(Arc::new(plan)) + } + + fn execute( + &self, + partition: usize, + context: Arc, + ) -> DataFusionResult { + let partition_count = self.input.output_partitioning().partition_count(); + if partition >= partition_count || (self.preserve_order && partition != 0) { + return Err(DataFusionError::Internal(format!( + "TakeRestoreExec cannot execute partition {partition}; input has {partition_count} partitions" + ))); + } + + let input = self.input.execute(partition, context)?; + let output_schema = self.schema.clone(); + let offsets = self.offsets.clone(); + let occurrences = self.occurrences.clone(); + let ordering_column = self.ordering_column.clone(); + let drop_ordering_column = self.drop_ordering_column; + let preserve_order = self.preserve_order; + let stream: Pin> + Send>> = + if preserve_order { + let input_schema = input.schema(); + Box::pin(stream::once(async move { + let batches = input.try_collect::>().await?; + let batch = if batches.is_empty() { + RecordBatch::new_empty(input_schema.clone()) + } else { + concat_batches(&input_schema, &batches)? + }; + restore_take_batch_with_occurrences( + batch, + &offsets, + &occurrences, + &ordering_column, + drop_ordering_column, + true, + ) + .map_err(|error| DataFusionError::External(Box::new(error))) + })) + } else { + Box::pin(input.map(move |batch| { + batch.and_then(|batch| { + restore_take_batch_with_occurrences( + batch, + &offsets, + &occurrences, + &ordering_column, + drop_ordering_column, + false, + ) + .map_err(|error| DataFusionError::External(Box::new(error))) + }) + })) + }; + + Ok(Box::pin(RecordBatchStreamAdapter::new( + output_schema, + stream, + ))) + } + + fn supports_limit_pushdown(&self) -> bool { + false + } +} + /// A builder for LanceDB take queries. /// /// See [`crate::Table::query`] for more details on queries @@ -1545,6 +1866,8 @@ impl HasQuery for VectorQuery { pub struct TakeQuery { parent: Arc, request: QueryRequest, + offsets: Option>, + preserve_order: bool, } impl TakeQuery { @@ -1552,15 +1875,24 @@ impl TakeQuery { /// /// See [`crate::Table::take_offsets`] for more details. pub fn from_offsets(parent: Arc, offsets: Vec) -> Self { - let in_list: Vec = offsets.iter().map(|o| lit(*o)).collect(); + let mut seen = HashSet::with_capacity(offsets.len()); + let in_list: Vec = offsets + .iter() + .copied() + .filter(|offset| seen.insert(*offset)) + .map(lit) + .collect(); Self { parent, request: QueryRequest { filter: Some(QueryFilter::Datafusion( col("_rowoffset").in_list(in_list, false), )), + take_offsets: Some(offsets.clone()), ..Default::default() }, + offsets: Some(offsets), + preserve_order: false, } } @@ -1575,9 +1907,181 @@ impl TakeQuery { filter: Some(QueryFilter::Datafusion(col(ROW_ID).in_list(in_list, false))), ..Default::default() }, + offsets: None, + preserve_order: false, } } + /// Preserve the requested offset order when restoring duplicate occurrences. + /// + /// This is reserved for readers whose API explicitly guarantees ordering. + pub(crate) fn preserve_order(mut self) -> Self { + debug_assert!(self.offsets.is_some()); + self.preserve_order = true; + self + } + + async fn request_with_row_offset( + parent: &dyn BaseTable, + request: &QueryRequest, + ) -> Result<(QueryRequest, String, bool)> { + const ROW_OFFSET: &str = "_rowoffset"; + const INTERNAL_ROW_OFFSET: &str = "__lancedb_take_row_offset"; + + let mut request = request.clone(); + // The physical lookup must not recursively restore occurrences. The + // wrapper above this request owns that logical operation. + request.take_offsets = None; + let (ordering_column, drop_ordering_column) = match &mut request.select { + Select::All => { + let mut columns = parent + .schema() + .await? + .fields() + .iter() + .map(|field| field.name().clone()) + .collect::>(); + columns.push(ROW_OFFSET.to_string()); + request.select = Select::Columns(columns); + (ROW_OFFSET.to_string(), true) + } + Select::Columns(columns) => { + if columns.iter().any(|column| column == ROW_OFFSET) { + (ROW_OFFSET.to_string(), false) + } else { + columns.push(ROW_OFFSET.to_string()); + (ROW_OFFSET.to_string(), true) + } + } + Select::Dynamic(columns) => { + let mut ordering_column = INTERNAL_ROW_OFFSET.to_string(); + while columns.iter().any(|(name, _)| name == &ordering_column) { + ordering_column.push('_'); + } + columns.push((ordering_column.clone(), ROW_OFFSET.to_string())); + (ordering_column, true) + } + Select::Expr(columns) => { + let mut ordering_column = INTERNAL_ROW_OFFSET.to_string(); + while columns.iter().any(|(name, _)| name == &ordering_column) { + ordering_column.push('_'); + } + columns.push((ordering_column.clone(), col(ROW_OFFSET))); + (ordering_column, true) + } + }; + + Ok((request, ordering_column, drop_ordering_column)) + } + + async fn prepare_offsets_lookup( + parent: &dyn BaseTable, + request: &QueryRequest, + ) -> Result<(QueryRequest, String, bool, usize, Option)> { + let (mut request, ordering_column, drop_ordering_column) = + Self::request_with_row_offset(parent, request).await?; + // The lookup operates on distinct physical rows. Pagination is a logical + // operation over occurrences and must be applied only after restoration. + let output_offset = request.offset.take().unwrap_or_default(); + let output_limit = request.limit.take(); + + Ok(( + request, + ordering_column, + drop_ordering_column, + output_offset, + output_limit, + )) + } + + fn wrap_offsets_plan( + lookup: Arc, + offsets: &[u64], + ordering_column: String, + drop_ordering_column: bool, + output_offset: usize, + output_limit: Option, + preserve_order: bool, + ) -> Result> { + let lookup = if preserve_order { + Arc::new(CoalescePartitionsExec::new(lookup)) as Arc + } else { + lookup + }; + let restored: Arc = Arc::new(TakeRestoreExec::try_new( + lookup, + offsets.to_vec(), + ordering_column, + drop_ordering_column, + preserve_order, + )?); + + if output_offset > 0 || output_limit.is_some() { + Ok(Arc::new(GlobalLimitExec::new( + restored, + output_offset, + output_limit, + ))) + } else { + Ok(restored) + } + } + + fn wrap_offsets_explanation( + lookup: &str, + occurrence_count: usize, + output_offset: usize, + output_limit: Option, + preserve_order: bool, + ) -> String { + fn indent(plan: &str, spaces: usize) -> String { + let indentation = " ".repeat(spaces); + plan.lines() + .map(|line| format!("{indentation}{line}")) + .collect::>() + .join("\n") + } + + let restored = if preserve_order { + format!( + "TakeRestoreExec: occurrences={occurrence_count}\n CoalescePartitionsExec\n{}", + indent(lookup, 4) + ) + } else { + format!( + "TakeRestoreExec: occurrences={occurrence_count}\n{}", + indent(lookup, 2) + ) + }; + + if output_offset > 0 || output_limit.is_some() { + let fetch = output_limit + .map(|limit| limit.to_string()) + .unwrap_or_else(|| "None".to_string()); + format!( + "GlobalLimitExec: skip={output_offset}, fetch={fetch}\n{}", + indent(&restored, 2) + ) + } else { + restored + } + } + + async fn create_offsets_plan( + &self, + offsets: &[u64], + options: QueryExecutionOptions, + ) -> Result> { + create_take_offsets_plan( + self.parent.as_ref(), + &self.request, + offsets, + options, + self.preserve_order, + ) + .await + } + /// Convert the `TakeQuery` into a `QueryRequest`. pub fn into_request(self) -> QueryRequest { self.request @@ -1622,6 +2126,63 @@ impl TakeQuery { } } +pub(crate) async fn create_take_offsets_plan( + parent: &dyn BaseTable, + request: &QueryRequest, + offsets: &[u64], + options: QueryExecutionOptions, + preserve_order: bool, +) -> Result> { + let (request, ordering_column, drop_ordering_column, output_offset, output_limit) = + TakeQuery::prepare_offsets_lookup(parent, request).await?; + let lookup_options = if preserve_order { + options.without_output_batch_length_limit() + } else { + options + }; + let lookup = parent + .create_plan(&AnyQuery::Query(request), lookup_options) + .await?; + + TakeQuery::wrap_offsets_plan( + lookup, + offsets, + ordering_column, + drop_ordering_column, + output_offset, + output_limit, + preserve_order, + ) +} + +pub(crate) async fn explain_take_offsets_plan( + parent: &dyn BaseTable, + request: &QueryRequest, + offsets: &[u64], + verbose: bool, +) -> Result { + let (request, _, _, output_offset, output_limit) = + TakeQuery::prepare_offsets_lookup(parent, request).await?; + let lookup = parent + .explain_plan(&AnyQuery::Query(request), verbose) + .await?; + Ok(TakeQuery::wrap_offsets_explanation( + &lookup, + offsets.len(), + output_offset, + output_limit, + false, + )) +} + +pub(crate) async fn prepare_take_offsets_request( + parent: &dyn BaseTable, + request: &QueryRequest, +) -> Result { + let (request, _, _, _, _) = TakeQuery::prepare_offsets_lookup(parent, request).await?; + Ok(request) +} + impl HasQuery for TakeQuery { fn mut_query(&mut self) -> &mut QueryRequest { &mut self.request @@ -1630,6 +2191,10 @@ impl HasQuery for TakeQuery { impl ExecutableQuery for TakeQuery { async fn create_plan(&self, options: QueryExecutionOptions) -> Result> { + if let Some(offsets) = &self.offsets { + return self.create_offsets_plan(offsets, options).await; + } + let req = AnyQuery::Query(self.request.clone()); self.parent.clone().create_plan(&req, options).await } @@ -1638,6 +2203,18 @@ impl ExecutableQuery for TakeQuery { &self, options: QueryExecutionOptions, ) -> Result { + if self.offsets.is_some() { + let plan = self.create_plan(options.clone()).await?; + let inner = execute_plan(plan, Default::default())?; + let inner = MaxBatchLengthStream::new_boxed(inner, options.max_batch_length as usize); + let inner = if let Some(timeout) = options.timeout { + TimeoutStream::new_boxed(inner, timeout) + } else { + inner + }; + return Ok(DatasetRecordBatchStream::new(inner).into()); + } + let query = AnyQuery::Query(self.request.clone()); Ok(SendableRecordBatchStream::from( self.parent.clone().query(&query, options).await?, @@ -1645,11 +2222,51 @@ impl ExecutableQuery for TakeQuery { } async fn explain_plan(&self, verbose: bool) -> Result { + if let Some(offsets) = &self.offsets { + let (request, _, _, output_offset, output_limit) = + Self::prepare_offsets_lookup(self.parent.as_ref(), &self.request).await?; + // Ask the backend to explain only the distinct-row lookup. This keeps + // remote explanation non-executing while still showing the client-side + // operators that create_plan and execution place above that lookup. + let lookup = self + .parent + .explain_plan(&AnyQuery::Query(request), verbose) + .await?; + return Ok(Self::wrap_offsets_explanation( + &lookup, + offsets.len(), + output_offset, + output_limit, + self.preserve_order, + )); + } + let query = AnyQuery::Query(self.request.clone()); self.parent.explain_plan(&query, verbose).await } async fn analyze_plan_with_options(&self, options: QueryExecutionOptions) -> Result { + if self.offsets.is_some() { + if self.parent.analyze_plan_is_remote() { + let (request, _, _, _, _) = + Self::prepare_offsets_lookup(self.parent.as_ref(), &self.request).await?; + // Remote analysis is owned by the service. The current wire + // request represents only the distinct-row lookup, so return + // the service report unchanged instead of fabricating metrics + // for client-side restoration operators. + return self + .parent + .analyze_plan(&AnyQuery::Query(request), options) + .await; + } + + let plan = self.create_plan(options).await?; + execute_plan(plan.clone(), Default::default())? + .try_collect::>() + .await?; + return Ok(format_analyzed_plan(plan)); + } + let query = AnyQuery::Query(self.request.clone()); self.parent.analyze_plan(&query, options).await } @@ -1670,6 +2287,7 @@ mod tests { StringArray, cast::AsArray, types::Float32Type, }; use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; + use datafusion_physical_plan::display::DisplayableExecutionPlan; use futures::{StreamExt, TryStreamExt}; use lance_testing::datagen::{BatchGenerator, IncrementingInt32, RandomVector}; use rand::seq::IndexedRandom; @@ -2924,6 +3542,218 @@ mod tests { assert_eq!(results[0].num_columns(), 1); } + #[tokio::test] + async fn test_take_offsets_preserves_duplicate_multiplicity() { + let tmp_dir = tempdir().unwrap(); + let table = make_test_table(&tmp_dir).await; + + let results = table + .take_offsets(vec![5, 1, 5, 17]) + .select(Select::Columns(vec!["id".to_string()])) + .execute_with_options(QueryExecutionOptions { + max_batch_length: 2, + ..Default::default() + }) + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + + assert_eq!(results.len(), 2); + assert!(results.iter().all(|batch| batch.num_columns() == 1)); + let mut ids = results + .iter() + .flat_map(|batch| { + batch + .column_by_name("id") + .unwrap() + .as_primitive::() + .values() + .to_vec() + }) + .collect::>(); + ids.sort_unstable(); + assert_eq!(ids, vec![1, 5, 5, 17]); + } + + #[tokio::test] + async fn test_take_offsets_plan_is_incremental() { + let tmp_dir = tempdir().unwrap(); + let table = make_test_table(&tmp_dir).await; + + let plan = table + .take_offsets(vec![5, 1, 17]) + .create_plan(QueryExecutionOptions { + max_batch_length: 1, + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(plan.properties().emission_type, EmissionType::Incremental); + let displayed = DisplayableExecutionPlan::new(plan.as_ref()) + .indent(false) + .to_string(); + assert!(displayed.contains("TakeRestoreExec")); + assert!(!displayed.contains("CoalescePartitionsExec")); + } + + #[tokio::test] + async fn test_take_into_request_preserves_duplicate_multiplicity() { + let tmp_dir = tempdir().unwrap(); + let table = make_test_table(&tmp_dir).await; + let request = table.take_offsets(vec![5, 5]).into_request(); + assert_eq!(request.take_offsets, Some(vec![5, 5])); + + let batches = table + .base_table() + .query(&AnyQuery::Query(request), QueryExecutionOptions::default()) + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + + assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::(), 2); + } + + #[test] + fn test_restore_take_batch_only_reorders_when_requested() { + let batch = RecordBatch::try_from_iter([ + ( + "id", + Arc::new(Int32Array::from(vec![17, 5, 1])) as Arc, + ), + ( + "_rowoffset", + Arc::new(UInt64Array::from(vec![17, 5, 1])) as Arc, + ), + ]) + .unwrap(); + + let restored = + restore_take_batch(batch.clone(), &[5, 1, 5, 17], "_rowoffset", true, false).unwrap(); + assert_eq!( + restored + .column_by_name("id") + .unwrap() + .as_primitive::() + .values(), + &[17, 5, 5, 1] + ); + + let ordered = restore_take_batch(batch, &[5, 1, 5, 17], "_rowoffset", true, true).unwrap(); + assert_eq!( + ordered + .column_by_name("id") + .unwrap() + .as_primitive::() + .values(), + &[5, 1, 5, 17] + ); + } + + #[tokio::test] + async fn test_take_offsets_applies_pagination_after_restoration() { + let tmp_dir = tempdir().unwrap(); + let table = make_test_table(&tmp_dir).await; + + let limited = table + .take_offsets(vec![0, 1, 0, 2]) + .select(Select::Columns(vec!["id".to_string()])) + .limit(3) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + let limited = concat_batches(&limited[0].schema(), &limited).unwrap(); + assert_eq!(limited.num_rows(), 3); + assert!( + limited + .column_by_name("id") + .unwrap() + .as_primitive::() + .values() + .iter() + .all(|id| [0, 1, 2].contains(id)) + ); + + let offset = table + .take_offsets(vec![5, 1, 5, 17]) + .select(Select::Columns(vec!["id".to_string()])) + .offset(1) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + let offset = concat_batches(&offset[0].schema(), &offset).unwrap(); + assert_eq!(offset.num_rows(), 3); + assert!( + offset + .column_by_name("id") + .unwrap() + .as_primitive::() + .values() + .iter() + .all(|id| [1, 5, 17].contains(id)) + ); + } + + #[tokio::test] + async fn test_take_offsets_create_plan_restores_occurrences() { + let tmp_dir = tempdir().unwrap(); + let table = make_test_table(&tmp_dir).await; + let take = table + .take_offsets(vec![5, 1, 5, 17]) + .select(Select::Columns(vec!["id".to_string()])); + + let plan = take + .create_plan(QueryExecutionOptions::default()) + .await + .unwrap(); + assert_eq!(plan.schema().fields().len(), 1); + assert_eq!(plan.schema().field(0).name(), "id"); + let planned = execute_plan(plan, Default::default()) + .unwrap() + .try_collect::>() + .await + .unwrap(); + let planned = concat_batches(&planned[0].schema(), &planned).unwrap(); + let mut ids = planned + .column_by_name("id") + .unwrap() + .as_primitive::() + .values() + .to_vec(); + ids.sort_unstable(); + assert_eq!(ids, vec![1, 5, 5, 17]); + } + + #[tokio::test] + async fn test_take_offsets_plan_introspection_shows_restoration() { + let tmp_dir = tempdir().unwrap(); + let table = make_test_table(&tmp_dir).await; + let take = table + .take_offsets(vec![0, 1, 0, 2]) + .select(Select::Columns(vec!["id".to_string()])) + .limit(3); + + let explained = take.explain_plan(false).await.unwrap(); + assert!(explained.contains("GlobalLimitExec")); + assert!(explained.contains("TakeRestoreExec")); + assert!(!explained.contains("CoalescePartitionsExec")); + + let analyzed = take.analyze_plan().await.unwrap(); + assert!(analyzed.contains("GlobalLimitExec")); + assert!(analyzed.contains("TakeRestoreExec")); + assert!(!analyzed.contains("CoalescePartitionsExec")); + } + #[tokio::test] async fn test_take_row_ids() { let tmp_dir = tempdir().unwrap(); diff --git a/rust/lancedb/src/remote.rs b/rust/lancedb/src/remote.rs index be9d0eef6..bc534c0f4 100644 --- a/rust/lancedb/src/remote.rs +++ b/rust/lancedb/src/remote.rs @@ -6,12 +6,15 @@ //! building client/server applications with LanceDB or as a client for some //! other custom LanceDB service. +pub mod catalog; pub(crate) mod client; pub(crate) mod db; pub(crate) mod job; pub mod oauth; mod retry; +pub(crate) mod sql; pub(crate) mod table; +pub(crate) mod token_cache; pub(crate) mod util; const ARROW_STREAM_CONTENT_TYPE: &str = "application/vnd.apache.arrow.stream"; @@ -30,4 +33,9 @@ fn extract_job_id(body: &str) -> Option { pub use client::{ClientConfig, HeaderProvider, RetryConfig, TimeoutConfig, TlsConfig}; pub use db::{RemoteDatabaseOptions, RemoteDatabaseOptionsBuilder}; -pub use oauth::{OAuthConfig, OAuthFlow, OAuthHeaderProvider}; +pub use oauth::{ + AuthorizationCodeOptions, ClientAuthMethod, OAuthConfig, OAuthFlow, OAuthHeaderProvider, +}; +pub use token_cache::{OAuthSession, SessionLogout, SessionStatus, TokenCacheOptions}; + +pub use catalog::{RemoteCatalog, RemoteCatalogOptions}; diff --git a/rust/lancedb/src/remote/catalog.rs b/rust/lancedb/src/remote/catalog.rs new file mode 100644 index 000000000..1c0129889 --- /dev/null +++ b/rust/lancedb/src/remote/catalog.rs @@ -0,0 +1,598 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use http::StatusCode; +use lance_namespace::models::{ + CreateNamespaceRequest, DescribeNamespaceRequest, DropNamespaceRequest, ListNamespacesRequest, +}; + +use super::db::RemoteDatabase; +use super::{ClientConfig, HeaderProvider, OAuthConfig, OAuthHeaderProvider}; +use crate::catalog::{ + Catalog, CreateDatabaseRequest, DropDatabaseRequest, ListDatabasesRequest, + ListDatabasesResponse, +}; +use crate::database::Database; +use crate::{Error, Result}; + +/// Authentication and client settings shared by a catalog and its databases. +#[derive(Clone, Default)] +#[non_exhaustive] +pub struct RemoteCatalogOptions { + /// Optional API key for catalog and database requests. + pub api_key: Option, + /// Shared transport and authentication settings. + pub client_config: ClientConfig, + /// SQL service endpoint used by returned database connections. + /// Required for SQL when the catalog endpoint uses HTTPS. + pub sql_host_override: Option, + /// Read consistency interval for tables opened in returned databases. + pub read_consistency_interval: Option, + /// OAuth authentication, mutually exclusive with an API key or header provider. + pub oauth_config: Option, +} + +impl std::fmt::Debug for RemoteCatalogOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteCatalogOptions") + .field("read_consistency_interval", &self.read_consistency_interval) + .finish_non_exhaustive() + } +} + +#[derive(Clone)] +pub(crate) struct ScopedHeaderProvider { + pub provider: Option>, + pub database: Option, +} + +impl std::fmt::Debug for ScopedHeaderProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScopedHeaderProvider") + .field("database", &self.database) + .finish_non_exhaustive() + } +} + +impl ScopedHeaderProvider { + pub(crate) fn apply(&self, headers: &mut HashMap) { + headers.retain(|name, _| { + !name.eq_ignore_ascii_case("x-lancedb-database") + && !name.eq_ignore_ascii_case("x-lancedb-database-prefix") + }); + if let Some(database) = &self.database { + headers.insert("x-lancedb-database".into(), database.clone()); + } + } +} + +#[async_trait] +impl HeaderProvider for ScopedHeaderProvider { + async fn get_headers(&self) -> Result> { + let mut headers = match &self.provider { + Some(provider) => provider.get_headers().await?, + None => HashMap::new(), + }; + self.apply(&mut headers); + Ok(headers) + } +} + +/// A catalog backed by the server's root namespace APIs. +/// +/// Database management requests omit database-selection headers. Opened database +/// connections retain their own scope and authentication independently. +pub struct RemoteCatalog { + endpoint: String, + root: RemoteDatabase, + options: RemoteCatalogOptions, +} + +impl fmt::Debug for RemoteCatalog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RemoteCatalog") + .field("uri", &self.endpoint) + .finish_non_exhaustive() + } +} + +impl RemoteCatalog { + /// Connect to an HTTP(S) root namespace endpoint. + /// + /// ``` + /// # use lancedb::remote::{RemoteCatalog, RemoteCatalogOptions}; + /// # fn example() -> lancedb::Result<()> { + /// let catalog = RemoteCatalog::try_new("https://my-server.example", RemoteCatalogOptions::default())?; + /// # Ok(()) + /// # } + /// ``` + pub fn try_new(endpoint: impl AsRef, mut options: RemoteCatalogOptions) -> Result { + let url = url::Url::parse(endpoint.as_ref()).map_err(|err| Error::InvalidInput { + message: format!("Invalid catalog endpoint: {err}"), + })?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(Error::InvalidInput { message: "Catalog endpoint must be an HTTP(S) URL without credentials, query, or fragment".into() }); + } + if options + .client_config + .id_delimiter + .as_ref() + .is_some_and(|d| d.is_empty()) + { + return Err(Error::InvalidInput { + message: "Catalog identifier delimiter cannot be empty".into(), + }); + } + if let Some(oauth) = options.oauth_config.take() { + if options.api_key.is_some() || options.client_config.header_provider.is_some() { + return Err(Error::InvalidInput { + message: "oauth_config cannot be combined with api_key or header_provider" + .into(), + }); + } + options.client_config.header_provider = + Some(Arc::new(OAuthHeaderProvider::new(oauth)?)); + } + let endpoint = url.to_string().trim_end_matches('/').to_string(); + let root = RemoteDatabase::for_catalog(&endpoint, None, &options)?; + Ok(Self { + endpoint, + root, + options, + }) + } + + fn validate_name(&self, name: &str) -> Result<()> { + let delimiter = self + .options + .client_config + .id_delimiter + .as_deref() + .unwrap_or("$"); + if name.is_empty() + || name.trim() != name + || !name.is_ascii() + || name.chars().any(char::is_control) + || name.contains(delimiter) + || matches!(name, "." | "..") + { + return Err(Error::InvalidInput { + message: format!( + "Invalid database name '{name}': expected a nonempty ASCII name without surrounding whitespace, control characters, or namespace delimiter '{delimiter}'" + ), + }); + } + Ok(()) + } + + fn database(&self, name: &str) -> Result> { + Ok(Arc::new(RemoteDatabase::for_catalog( + &self.endpoint, + Some(name), + &self.options, + )?)) + } + + fn map_missing(name: &str, err: Error) -> Error { + match err { + Error::Http { + status_code: Some(StatusCode::NOT_FOUND), + .. + } => Error::DatabaseNotFound { name: name.into() }, + err => err, + } + } +} + +#[async_trait] +impl Catalog for RemoteCatalog { + fn uri(&self) -> &str { + &self.endpoint + } + + async fn create_database(&self, request: CreateDatabaseRequest) -> Result> { + self.validate_name(&request.name)?; + self.root + .create_namespace(CreateNamespaceRequest { + id: Some(vec![request.name.clone()]), + mode: Some( + if request.exist_ok { + "ExistOk" + } else { + "Create" + } + .into(), + ), + ..Default::default() + }) + .await + .map_err(|err| match err { + Error::Http { + status_code: Some(StatusCode::CONFLICT), + .. + } => Error::DatabaseAlreadyExists { + name: request.name.clone(), + }, + err => err, + })?; + self.database(&request.name) + } + + async fn drop_database(&self, request: DropDatabaseRequest) -> Result<()> { + self.validate_name(&request.name)?; + let result = self + .root + .drop_namespace(DropNamespaceRequest { + id: Some(vec![request.name.clone()]), + mode: Some( + if request.ignore_missing { + "Skip" + } else { + "Fail" + } + .into(), + ), + behavior: Some("Restrict".into()), + ..Default::default() + }) + .await; + match result { + Ok(_) => Ok(()), + Err(Error::Http { + status_code: Some(StatusCode::NOT_FOUND), + .. + }) if request.ignore_missing => Ok(()), + Err(err) => Err(Self::map_missing(&request.name, err)), + } + } + + async fn list_databases(&self, request: ListDatabasesRequest) -> Result { + let limit = request + .limit + .map(|limit| { + i32::try_from(limit) + .ok() + .filter(|limit| *limit > 0) + .ok_or_else(|| Error::InvalidInput { + message: "Database list limit must be between 1 and 2147483647".into(), + }) + }) + .transpose()?; + let response = self + .root + .list_namespaces(ListNamespacesRequest { + id: Some(vec![]), + limit, + page_token: request.page_token, + ..Default::default() + }) + .await?; + Ok(ListDatabasesResponse { + databases: response.namespaces, + page_token: response.page_token.filter(|token| !token.is_empty()), + }) + } + + async fn connect_database(&self, name: &str) -> Result> { + self.validate_name(name)?; + self.root + .describe_namespace(DescribeNamespaceRequest { + id: Some(vec![name.into()]), + ..Default::default() + }) + .await + .map_err(|err| Self::map_missing(name, err))?; + self.database(name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::catalog::CatalogConnection; + use serde_json::{Value, json}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::task::JoinHandle; + + #[derive(Debug)] + struct Request { + line: String, + headers: HashMap, + body: Value, + } + + async fn server(responses: Vec<(u16, Value)>) -> (String, JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let header_end = loop { + let mut buf = [0; 4096]; + let n = socket.read(&mut buf).await.unwrap(); + assert!(n > 0); + bytes.extend_from_slice(&buf[..n]); + if let Some(pos) = bytes.windows(4).position(|b| b == b"\r\n\r\n") { + break pos + 4; + } + }; + let header = String::from_utf8(bytes[..header_end].to_vec()).unwrap(); + let mut lines = header.lines(); + let line = lines.next().unwrap().to_string(); + let headers: HashMap<_, _> = lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string())) + .collect(); + let length: usize = headers + .get("content-length") + .map(|s| s.parse().unwrap()) + .unwrap_or(0); + while bytes.len() < header_end + length { + let mut buf = [0; 4096]; + let n = socket.read(&mut buf).await.unwrap(); + assert!(n > 0); + bytes.extend_from_slice(&buf[..n]); + } + let request_body = if length == 0 { + Value::Null + } else { + serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap() + }; + requests.push(Request { + line, + headers, + body: request_body, + }); + let body = if status == 204 { + String::new() + } else { + body.to_string() + }; + socket.write_all(format!("HTTP/1.1 {status} Response\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + requests + }); + (endpoint, task) + } + + #[derive(Debug)] + struct AuthProvider; + + #[async_trait] + impl HeaderProvider for AuthProvider { + async fn get_headers(&self) -> Result> { + Ok(HashMap::from([ + ("Authorization".into(), "Bearer refreshed".into()), + ("X-LanceDB-Database".into(), "wrong-dynamic".into()), + ("X-LanceDB-Database-Prefix".into(), "wrong-prefix".into()), + ])) + } + } + + #[tokio::test] + async fn catalog_routes_root_and_independent_database_scopes() { + let (endpoint, task) = server(vec![ + ( + 200, + json!({"namespaces": ["team/search"], "page_token": "next"}), + ), + (204, Value::Null), + (200, json!({"namespaces": []})), + (200, json!({})), + (200, json!({"namespaces": []})), + (200, json!({"namespaces": []})), + (200, json!({"namespaces": [], "page_token": ""})), + (204, Value::Null), + ]) + .await; + let mut options = RemoteCatalogOptions { + api_key: Some("test-key".into()), + ..Default::default() + }; + options.client_config.extra_headers = HashMap::from([ + ("x-lancedb-database".into(), "wrong-static".into()), + ( + "x-lancedb-database-prefix".into(), + "wrong-static-prefix".into(), + ), + ]); + options.client_config.header_provider = Some(Arc::new(AuthProvider)); + let catalog = CatalogConnection::new(Arc::new( + RemoteCatalog::try_new(&endpoint, options).unwrap(), + )); + let page = catalog + .list_databases(ListDatabasesRequest::default().limit(1).page_token("a/b")) + .await + .unwrap(); + assert_eq!(page.databases, ["team/search"]); + assert_eq!(page.page_token.as_deref(), Some("next")); + let first = catalog + .create_database(CreateDatabaseRequest::new("team/search").exist_ok(true)) + .await + .unwrap(); + first + .database() + .list_namespaces(ListNamespacesRequest::default()) + .await + .unwrap(); + let second = catalog.connect_database("other").await.unwrap(); + second + .database() + .list_namespaces(ListNamespacesRequest::default()) + .await + .unwrap(); + first + .database() + .list_namespaces(ListNamespacesRequest::default()) + .await + .unwrap(); + assert!( + catalog + .list_databases(ListDatabasesRequest::default()) + .await + .unwrap() + .page_token + .is_none() + ); + catalog + .drop_database(DropDatabaseRequest::new("team/search").ignore_missing(true)) + .await + .unwrap(); + let requests = task.await.unwrap(); + for (i, request) in requests.iter().enumerate() { + let database = match i { + 2 | 5 => Some("team/search"), + 4 => Some("other"), + _ => None, + }; + assert_eq!( + request + .headers + .get("x-lancedb-database") + .map(String::as_str), + database + ); + assert!(!request.headers.contains_key("x-lancedb-database-prefix")); + assert_eq!(request.headers["authorization"], "Bearer refreshed"); + } + assert_eq!( + requests[0].line, + "GET /v1/namespace/$/list?limit=1&page_token=a%2Fb HTTP/1.1" + ); + assert_eq!( + requests[1].line, + "POST /v1/namespace/team%2Fsearch/create HTTP/1.1" + ); + assert_eq!(requests[1].body, json!({"mode": "ExistOk"})); + assert_eq!( + requests[3].line, + "POST /v1/namespace/other/describe HTTP/1.1" + ); + assert_eq!( + requests[7].body, + json!({"mode": "Skip", "behavior": "Restrict"}) + ); + } + + #[tokio::test] + async fn catalog_preserves_errors_and_never_cascades() { + let (endpoint, task) = server(vec![ + (404, json!({"error": "missing"})), + (409, json!({"error": "exists"})), + (400, json!({"error": "not empty"})), + (404, json!({"error": "missing"})), + (404, json!({"error": "missing"})), + (401, json!({"error": "unauthorized"})), + ]) + .await; + let catalog = RemoteCatalog::try_new(endpoint, RemoteCatalogOptions::default()).unwrap(); + assert!(matches!( + catalog.connect_database("missing").await, + Err(Error::DatabaseNotFound { .. }) + )); + assert!(matches!( + catalog.create_database("exists".into()).await, + Err(Error::DatabaseAlreadyExists { .. }) + )); + assert!(matches!( + catalog.drop_database("full".into()).await, + Err(Error::Http { + status_code: Some(StatusCode::BAD_REQUEST), + .. + }) + )); + assert!(matches!( + catalog.drop_database("missing".into()).await, + Err(Error::DatabaseNotFound { .. }) + )); + catalog + .drop_database(DropDatabaseRequest::new("missing").ignore_missing(true)) + .await + .unwrap(); + assert!( + catalog + .list_databases(ListDatabasesRequest::default()) + .await + .is_err() + ); + let requests = task.await.unwrap(); + assert_eq!(requests[1].body, json!({"mode": "Create"})); + assert_eq!( + requests[2].body, + json!({"mode": "Fail", "behavior": "Restrict"}) + ); + } + + #[tokio::test] + async fn catalog_validates_before_sending_requests() { + for endpoint in [ + "/tmp/catalog", + "s3://bucket", + "db://database", + "https://user:pass@host", + "https://host?q=1", + "https://host#fragment", + ] { + assert!(RemoteCatalog::try_new(endpoint, RemoteCatalogOptions::default()).is_err()); + } + let catalog = + RemoteCatalog::try_new("http://127.0.0.1:1", RemoteCatalogOptions::default()).unwrap(); + for name in ["", "a$b", "\r\ninjected", "..", "café", " padded "] { + assert!(matches!( + catalog.create_database(name.into()).await, + Err(Error::InvalidInput { .. }) + )); + assert!(matches!( + catalog.connect_database(name).await, + Err(Error::InvalidInput { .. }) + )); + assert!(matches!( + catalog.drop_database(name.into()).await, + Err(Error::InvalidInput { .. }) + )); + } + for limit in [0, u32::MAX] { + assert!(matches!( + catalog + .list_databases(ListDatabasesRequest::default().limit(limit)) + .await, + Err(Error::InvalidInput { .. }) + )); + } + } + + #[test] + fn catalog_debug_redacts_credentials() { + let mut options = RemoteCatalogOptions { + api_key: Some("catalog-secret-key".into()), + ..Default::default() + }; + options + .client_config + .extra_headers + .insert("authorization".into(), "Bearer catalog-secret-token".into()); + let catalog = RemoteCatalog::try_new("https://catalog.example", options).unwrap(); + let catalog_debug = format!("{catalog:?}"); + let connection = CatalogConnection::new(Arc::new(catalog)); + for debug in [catalog_debug, format!("{connection:?}")] { + assert!(debug.contains("https://catalog.example")); + assert!(!debug.contains("catalog-secret-key")); + assert!(!debug.contains("catalog-secret-token")); + } + } +} diff --git a/rust/lancedb/src/remote/client.rs b/rust/lancedb/src/remote/client.rs index 57dd89890..b7a492598 100644 --- a/rust/lancedb/src/remote/client.rs +++ b/rust/lancedb/src/remote/client.rs @@ -15,6 +15,25 @@ use crate::remote::retry::{ResolvedRetryConfig, RetryCounter}; const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id"); +pub fn redact_sensitive_headers(headers: &mut HeaderMap) { + const SENSITIVE_HEADERS: [&str; 5] = [ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + ]; + + for (name, value) in headers.iter_mut() { + if SENSITIVE_HEADERS + .iter() + .any(|sensitive| name.as_str().eq_ignore_ascii_case(sensitive)) + { + value.set_sensitive(true); + } + } +} + /// Configuration for TLS/mTLS settings. #[derive(Clone, Debug)] pub struct TlsConfig { @@ -71,8 +90,9 @@ pub struct ClientConfig { pub user_agent: String, // TODO: how to configure request ids? pub extra_headers: HashMap, - /// The delimiter to use when constructing object identifiers. - /// If not default, passes as query parameter. + /// The delimiter joining a namespace path and a name into one object + /// identifier. [`ID_DELIMITER`] is the only accepted value; any other is + /// refused by [`ClientConfig::validate`]. pub id_delimiter: Option, /// TLS configuration for mTLS support pub tls_config: Option, @@ -288,7 +308,6 @@ pub struct RestfulLanceDbClient { host: String, pub(crate) retry_config: ResolvedRetryConfig, pub(crate) sender: S, - pub(crate) id_delimiter: String, pub(crate) header_provider: Option>, /// Connection-level read consistency interval. Drives the /// `x-lancedb-min-timestamp` freshness header sent on read requests. @@ -311,7 +330,6 @@ impl std::fmt::Debug for RestfulLanceDbClient { .field("host", &self.host) .field("retry_config", &self.retry_config) .field("sender", &self.sender) - .field("id_delimiter", &self.id_delimiter) .field( "header_provider", &self.header_provider.as_ref().map(|_| "Some(...)"), @@ -360,7 +378,11 @@ pub fn parse_db_url(db_url: &str) -> Result { message: format!("Invalid database URL (missing host) '{}'", db_url), }); } - let db_name = parsed_url.host_str().unwrap().to_string(); + let db_name = urlencoding::decode(parsed_url.host_str().unwrap()) + .map_err(|err| Error::InvalidInput { + message: format!("Invalid encoded database name: {err}"), + })? + .into_owned(); let db_prefix = { let prefix = parsed_url.path().trim_start_matches('/'); if prefix.is_empty() { @@ -404,6 +426,54 @@ fn validate_dns_hostname(hostname: &str) -> Result<()> { Ok(()) } +/// Whether a request's body may appear in a debug log. +/// +/// The API that built the body decides. The transport cannot know which +/// payloads are credentials, and a list of routes here would have to be kept in +/// step with endpoints defined elsewhere -- so the knowledge lives with the +/// call that has it. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum BodyLogging { + /// Log the body at debug. The default: a request body is diagnostic unless + /// the call that built it says otherwise. + Allowed, + /// Never log the body. For a request whose body is a credential. + Suppressed, +} + +/// The delimiter joining a namespace path and a name into the `{id}` a route +/// addresses, and the only one a LanceDB service splits on. +/// +/// `$` is outside the character set object names admit, so a joined identifier +/// always splits back into the parts that made it. The configuration field +/// exists because the identifier grammar comes from the Lance REST catalog +/// standard, which carries a delimiter setting for other catalogs to adopt. +pub(crate) const ID_DELIMITER: &str = "$"; + +fn validate_id_delimiter(delimiter: &str) -> Result<()> { + if delimiter != ID_DELIMITER { + return Err(Error::InvalidInput { + message: format!( + "id_delimiter '{delimiter}' is not supported: '{ID_DELIMITER}' is the only \ + delimiter LanceDB services split an identifier on" + ), + }); + } + Ok(()) +} + +impl ClientConfig { + /// Check the settings a request cannot be built correctly without, so a + /// mistake is reported where it was made rather than as a confusing + /// response later. Public so a caller can ask without connecting. + pub fn validate(&self) -> Result<()> { + if let Some(delimiter) = &self.id_delimiter { + validate_id_delimiter(delimiter)?; + } + Ok(()) + } +} + impl RestfulLanceDbClient { fn get_timeout(passed: Option, env_var: &str) -> Result> { if let Some(passed) = passed { @@ -429,6 +499,10 @@ impl RestfulLanceDbClient { client_config: ClientConfig, read_consistency_interval: Option, ) -> Result { + // Before anything is built from it, so the error names the caller's + // configuration rather than a request. + client_config.validate()?; + // Get the timeouts let timeout = Self::get_timeout(client_config.timeout_config.timeout, "LANCE_CLIENT_TIMEOUT")?; @@ -528,10 +602,6 @@ impl RestfulLanceDbClient { host, retry_config, sender: Sender, - id_delimiter: client_config - .id_delimiter - .clone() - .unwrap_or("$".to_string()), header_provider: client_config.header_provider, read_consistency_interval, max_bytes_per_request, @@ -610,12 +680,14 @@ impl RestfulLanceDbClient { ) -> Result { let mut headers = HeaderMap::new(); if !api_key.is_empty() { - headers.insert( - HeaderName::from_static("x-api-key"), - HeaderValue::from_str(api_key).map_err(|_| Error::InvalidInput { - message: "non-ascii api key provided".to_string(), - })?, - ); + // `log_request` prints the request's Debug, which prints headers. + // Marking the value sensitive is what makes that print `Sensitive` + // instead of the key itself. + let mut key = HeaderValue::from_str(api_key).map_err(|_| Error::InvalidInput { + message: "non-ascii api key provided".to_string(), + })?; + key.set_sensitive(true); + headers.insert(HeaderName::from_static("x-api-key"), key); } if region == "local" { let host = format!("{}.local.api.lancedb.com", db_name); @@ -681,27 +753,18 @@ impl RestfulLanceDbClient { ); } + redact_sensitive_headers(&mut headers); Ok(headers) } pub fn get(&self, uri: &str) -> RequestBuilder { let full_uri = format!("{}{}", self.host, uri); - let builder = self.client.get(full_uri); - self.add_id_delimiter_query_param(builder) + self.client.get(full_uri) } pub fn post(&self, uri: &str) -> RequestBuilder { let full_uri = format!("{}{}", self.host, uri); - let builder = self.client.post(full_uri); - self.add_id_delimiter_query_param(builder) - } - - fn add_id_delimiter_query_param(&self, req: RequestBuilder) -> RequestBuilder { - if self.id_delimiter != "$" { - req.query(&[("delimiter", self.id_delimiter.clone())]) - } else { - req - } + self.client.post(full_uri) } /// Apply dynamic headers from the header provider if configured @@ -714,17 +777,34 @@ impl RestfulLanceDbClient { if let Ok(header_value) = HeaderValue::from_str(&value) { request_headers.insert(header_name, header_value); } else { - debug!("Invalid header value for key {}: {}", key, value); + debug!("Invalid header value for key {}", key); } } else { debug!("Invalid header name: {}", key); } } } + redact_sensitive_headers(request.headers_mut()); Ok(request) } pub async fn send(&self, req: RequestBuilder) -> Result<(String, Response)> { + self.send_logging(req, BodyLogging::Allowed).await + } + + /// Send a request whose body must never reach a debug log. + /// + /// The body is built by the caller, so only the caller knows it holds a + /// credential; `log_request` sees serialized bytes and cannot tell. + pub async fn send_suppressing_body(&self, req: RequestBuilder) -> Result<(String, Response)> { + self.send_logging(req, BodyLogging::Suppressed).await + } + + async fn send_logging( + &self, + req: RequestBuilder, + body_logging: BodyLogging, + ) -> Result<(String, Response)> { let (client, request) = req.build_split(); let mut request = request.unwrap(); let request_id = self.extract_request_id(&mut request); @@ -732,7 +812,7 @@ impl RestfulLanceDbClient { // Apply dynamic headers before sending request = self.apply_dynamic_headers(request).await?; - self.log_request(&request, &request_id); + self.log_request(&request, &request_id, body_logging); let response = self .sender @@ -795,7 +875,7 @@ impl RestfulLanceDbClient { // Apply dynamic headers before each retry attempt request = self.apply_dynamic_headers(request).await?; - self.log_request(&request, &request_id); + self.log_request(&request, &request_id, BodyLogging::Allowed); let response = self.sender.send(&c, request).await.map(|r| (r.status(), r)); @@ -839,13 +919,18 @@ impl RestfulLanceDbClient { } } - pub(crate) fn log_request(&self, request: &Request, request_id: &String) { + fn log_request(&self, request: &Request, request_id: &String, body_logging: BodyLogging) { if log::log_enabled!(log::Level::Debug) { let content_type = request .headers() .get("content-type") .map(|v| v.to_str().unwrap()); - if content_type == Some("application/json") { + if body_logging == BodyLogging::Suppressed { + debug!( + "Sending request_id={}: {:?} with body suppressed", + request_id, request + ); + } else if content_type == Some("application/json") { let body = request.body().as_ref().unwrap().as_bytes().unwrap(); let body = String::from_utf8_lossy(body); debug!( @@ -1022,7 +1107,6 @@ pub mod test_utils { sender: MockSender { f: Arc::new(wrapper), }, - id_delimiter: "$".to_string(), header_provider: None, read_consistency_interval, max_bytes_per_request: None, @@ -1049,7 +1133,6 @@ pub mod test_utils { sender: MockSender { f: Arc::new(wrapper), }, - id_delimiter: config.id_delimiter.unwrap_or_else(|| "$".to_string()), header_provider: config.header_provider, read_consistency_interval: None, max_bytes_per_request: config @@ -1064,6 +1147,40 @@ pub mod test_utils { #[cfg(test)] mod tests { + /// A configuration naming any other delimiter is refused where it was + /// written, rather than producing identifiers no service splits the way the + /// caller meant. + #[test] + fn test_a_delimiter_other_than_the_supported_one_is_refused() { + for delimiter in ["/", "?", "#", "%", "", ".", "..", "-", "_", "|", "::", "$$"] { + let error = super::validate_id_delimiter(delimiter) + .expect_err("only the supported delimiter may be configured"); + assert!( + error.to_string().contains("id_delimiter"), + "{delimiter:?}: {error}" + ); + } + super::validate_id_delimiter(super::ID_DELIMITER).unwrap(); + } + + /// Leaving it unset is how nearly every caller reaches the same delimiter. + #[test] + fn test_an_unset_delimiter_is_the_supported_one() { + super::ClientConfig::default().validate().unwrap(); + super::ClientConfig { + id_delimiter: Some(super::ID_DELIMITER.to_string()), + ..Default::default() + } + .validate() + .unwrap(); + super::ClientConfig { + id_delimiter: Some("-".to_string()), + ..Default::default() + } + .validate() + .expect_err("a configured delimiter other than the supported one must be refused"); + } + use super::*; use serial_test::serial; use std::time::Duration; @@ -1077,6 +1194,16 @@ mod tests { ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) } + #[test] + fn test_parse_catalog_database_uri() { + let parsed = parse_db_url("db://team%2Fsearch").unwrap(); + assert_eq!(parsed.db_name, "team/search"); + assert!(parsed.db_prefix.is_none()); + let parsed = parse_db_url("db://db/prefix").unwrap(); + assert_eq!(parsed.db_name, "db"); + assert_eq!(parsed.db_prefix.as_deref(), Some("prefix")); + } + #[test] fn test_timeout_config_default() { let config = TimeoutConfig::default(); @@ -1180,7 +1307,7 @@ mod tests { assert!(!headers.contains_key("x-api-key")); let headers = RestfulLanceDbClient::::default_headers( - "api-key", + "static-secret-value", "us-east-1", "db-name", false, @@ -1189,7 +1316,90 @@ mod tests { &ClientConfig::default(), ) .unwrap(); - assert_eq!(headers.get("x-api-key").unwrap(), "api-key"); + let api_key = headers.get("x-api-key").unwrap(); + assert_eq!(api_key, "static-secret-value"); + assert!(api_key.is_sensitive()); + assert!(!format!("{headers:?}").contains("static-secret-value")); + } + + #[test] + fn test_configured_authentication_headers_are_sensitive() { + let config = ClientConfig { + extra_headers: HashMap::from([ + ( + "Authorization".to_string(), + "Bearer configured-secret".to_string(), + ), + ("X-API-Key".to_string(), "configured-api-key".to_string()), + ( + "Cookie".to_string(), + "session=configured-cookie".to_string(), + ), + ( + "Proxy-Authorization".to_string(), + "Basic configured-proxy-secret".to_string(), + ), + ("X-Custom".to_string(), "visible-value".to_string()), + ]), + ..Default::default() + }; + let headers = RestfulLanceDbClient::::default_headers( + "", + "us-east-1", + "db-name", + false, + &RemoteOptions::default(), + None, + &config, + ) + .unwrap(); + + assert!(headers.get("authorization").unwrap().is_sensitive()); + assert!(headers.get("x-api-key").unwrap().is_sensitive()); + assert!(headers.get("cookie").unwrap().is_sensitive()); + assert!(headers.get("proxy-authorization").unwrap().is_sensitive()); + assert!(!headers.get("x-custom").unwrap().is_sensitive()); + let debug = format!("{headers:?}"); + assert!(!debug.contains("configured-secret")); + assert!(!debug.contains("configured-api-key")); + assert!(!debug.contains("configured-cookie")); + assert!(!debug.contains("configured-proxy-secret")); + assert!(debug.contains("visible-value")); + } + + /// `log_request` prints the request's Debug, and Debug for a request prints + /// its headers. Marking the value sensitive is the only thing standing + /// between the API key and every debug line; assert on the header map's own + /// Debug, which is what that printing reduces to. + #[test] + fn test_api_key_is_redacted_in_debug_output() { + let headers = RestfulLanceDbClient::::default_headers( + "sk-live-sentinel", + "us-east-1", + "db-name", + false, + &RemoteOptions::default(), + None, + &ClientConfig::default(), + ) + .unwrap(); + + assert_eq!(headers.get("x-api-key").unwrap(), "sk-live-sentinel"); + assert!( + !format!("{:?}", headers).contains("sk-live-sentinel"), + "the API key must not survive Debug formatting" + ); + } + + /// A suppressed body is suppressed whatever the content type says, and an + /// allowed one is logged in full. + #[test] + fn test_body_logging_is_decided_by_the_caller() { + assert_ne!(BodyLogging::Allowed, BodyLogging::Suppressed); + // `send` and `send_suppressing_body` differ only in what they pass, so + // the enum is the whole contract: a caller states its intent and the + // transport does not infer one from the route. + assert_eq!(BodyLogging::Allowed, BodyLogging::Allowed); } #[test] @@ -1281,7 +1491,6 @@ mod tests { host: "https://example.com".to_string(), retry_config: RetryConfig::default().try_into().unwrap(), sender: Sender, - id_delimiter: "+".to_string(), header_provider: Some(Arc::new(provider) as Arc), read_consistency_interval: None, max_bytes_per_request: None, @@ -1303,6 +1512,7 @@ mod tests { // Test that dynamic headers override existing headers let mut headers = HashMap::new(); headers.insert("Authorization".to_string(), "Bearer new-token".to_string()); + headers.insert("X-API-Key".to_string(), "new-api-key".to_string()); headers.insert("X-Custom".to_string(), "custom-value".to_string()); let provider = TestHeaderProvider::new(headers); @@ -1319,7 +1529,6 @@ mod tests { host: "https://example.com".to_string(), retry_config: RetryConfig::default().try_into().unwrap(), sender: Sender, - id_delimiter: "+".to_string(), header_provider: Some(Arc::new(provider) as Arc), read_consistency_interval: None, max_bytes_per_request: None, @@ -1338,6 +1547,31 @@ mod tests { updated_request.headers().get("X-Custom").unwrap(), "custom-value" ); + assert!( + updated_request + .headers() + .get("Authorization") + .unwrap() + .is_sensitive() + ); + assert!( + updated_request + .headers() + .get("X-API-Key") + .unwrap() + .is_sensitive() + ); + assert!( + !updated_request + .headers() + .get("X-Custom") + .unwrap() + .is_sensitive() + ); + let debug = format!("{updated_request:?}"); + assert!(!debug.contains("new-token")); + assert!(!debug.contains("new-api-key")); + assert!(debug.contains("custom-value")); // Existing headers should still be present assert_eq!( updated_request.headers().get("X-Existing").unwrap(), @@ -1359,7 +1593,6 @@ mod tests { host: "https://example.com".to_string(), retry_config: RetryConfig::default().try_into().unwrap(), sender: Sender, - id_delimiter: "+".to_string(), header_provider: Some(Arc::new(provider) as Arc), read_consistency_interval: None, max_bytes_per_request: None, diff --git a/rust/lancedb/src/remote/db.rs b/rust/lancedb/src/remote/db.rs index da9a4b09b..bc1dbfea1 100644 --- a/rust/lancedb/src/remote/db.rs +++ b/rust/lancedb/src/remote/db.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use async_trait::async_trait; @@ -20,19 +20,28 @@ use lance_namespace::models::{ use crate::Error; use crate::database::{ - CloneTableRequest, CreateTableMode, CreateTableRequest, Database, DatabaseOptions, - JobDescription, JobInfo, OpenTableRequest, ReadConsistency, TableNamesRequest, + CloneTableRequest, CreateTableMode, CreateTableRequest, Database, DatabaseOptions, JobInfo, + OpenTableRequest, ReadConsistency, TableNamesRequest, }; use crate::error::Result; -use crate::function::{FunctionRegistrationRequest, FunctionVersion}; +use crate::function::{ + FunctionArtifactRequest, FunctionRegistrationRequest, FunctionSignature, FunctionVersion, + PythonRuntimeSpec, +}; use crate::job::Job; -use crate::remote::job::{DescribeJobResponse, RemoteJob, job_state_to_client}; +use crate::materialized_view::CreateMaterializedViewRequest; +use crate::remote::job::{RemoteJob, job_state_to_client}; use crate::remote::util::stream_as_body; +use crate::secrets::SecretBinding; +use crate::secrets::SecretInfo; use crate::table::BaseTable; +use crate::utils::{reject_relative_segment, validate_table_name}; use super::client::{ - ClientConfig, HeaderProvider, HttpSend, RequestResultExt, RestfulLanceDbClient, Sender, + ClientConfig, HeaderProvider, HttpSend, ID_DELIMITER, RequestResultExt, RestfulLanceDbClient, + Sender, }; +use super::sql::SqlClient; use super::table::RemoteTable; use super::util::parse_server_version; use super::{ARROW_STREAM_CONTENT_TYPE, extract_job_id}; @@ -97,6 +106,7 @@ pub const OPT_REMOTE_PREFIX: &str = "remote_database_"; pub const OPT_REMOTE_API_KEY: &str = "remote_database_api_key"; pub const OPT_REMOTE_REGION: &str = "remote_database_region"; pub const OPT_REMOTE_HOST_OVERRIDE: &str = "remote_database_host_override"; +pub const OPT_REMOTE_SQL_HOST_OVERRIDE: &str = "remote_database_sql_host_override"; // TODO: add support for configuring client config via key/value options #[derive(Clone, Debug, Default)] @@ -212,6 +222,7 @@ pub struct RemoteDatabase { namespace_context_provider: Option>, /// TLS configuration for mTLS support tls_config: Option, + sql_client: Option, } #[derive(Clone)] @@ -269,22 +280,95 @@ impl DynamicContextProvider for NamespaceHeaderProviderContext { } } +pub struct RemoteHostOverrides { + pub rest: Option, + pub sql: Option, +} + impl RemoteDatabase { - pub fn try_new( + pub(crate) fn try_new( uri: &str, api_key: &str, region: &str, - host_override: Option, + host_overrides: RemoteHostOverrides, client_config: ClientConfig, options: RemoteOptions, read_consistency_interval: Option, ) -> Result { let parsed = super::client::parse_db_url(uri)?; + Self::try_new_with_identity( + uri, + api_key, + region, + host_overrides, + client_config, + options, + read_consistency_interval, + parsed, + ) + } + + pub(crate) fn for_catalog( + endpoint: &str, + name: Option<&str>, + options: &super::catalog::RemoteCatalogOptions, + ) -> Result { + let scope = super::catalog::ScopedHeaderProvider { + provider: options.client_config.header_provider.clone(), + database: name.map(str::to_string), + }; + let mut config = options.client_config.clone(); + scope.apply(&mut config.extra_headers); + config.header_provider = Some(Arc::new(scope)); + let uri = name + .map(|name| format!("db://{}", urlencoding::encode(name))) + .unwrap_or_else(|| endpoint.to_string()); + let mut db = Self::try_new_with_identity( + &uri, + options.api_key.as_deref().unwrap_or(""), + "us-east-1", + RemoteHostOverrides { + rest: Some(endpoint.to_string()), + sql: options.sql_host_override.clone(), + }, + config, + RemoteOptions::default(), + options.read_consistency_interval, + super::client::ParsedDbUrl { + db_name: name.unwrap_or("").to_string(), + db_prefix: None, + }, + )?; + if name.is_none() { + db.sql_client = None; + } + Ok(db) + } + + #[allow(clippy::too_many_arguments)] + fn try_new_with_identity( + uri: &str, + api_key: &str, + region: &str, + host_overrides: RemoteHostOverrides, + client_config: ClientConfig, + options: RemoteOptions, + read_consistency_interval: Option, + parsed: super::client::ParsedDbUrl, + ) -> Result { + let sql_client = SqlClient::new( + parsed.db_name.clone(), + parsed.db_prefix.clone(), + api_key.to_string(), + host_overrides.rest.clone(), + host_overrides.sql, + client_config.clone(), + ); let header_map = RestfulLanceDbClient::::default_headers( api_key, region, &parsed.db_name, - host_override.is_some(), + host_overrides.rest.is_some() && !parsed.db_name.is_empty(), &options, parsed.db_prefix.as_deref(), &client_config, @@ -312,7 +396,7 @@ impl RemoteDatabase { let client = RestfulLanceDbClient::try_new( &parsed, region, - host_override, + host_overrides.rest, header_map, client_config.clone(), read_consistency_interval, @@ -330,17 +414,36 @@ impl RemoteDatabase { namespace_headers, namespace_context_provider, tls_config: client_config.tls_config, + sql_client: Some(sql_client), }) } } impl RemoteDatabase { + /// Post a request whose body carries a credential. + /// + /// Shared by the create and alter verbs, which declare their own request + /// types: the two mean different things to the service and are free to + /// diverge, so what they share is the posting and not the shape. + /// + /// The value is a request field and never a path segment or query + /// parameter, which keeps it out of access logs and proxy traces. + async fn post_secret_write(&self, route: &str, body: &T) -> Result<()> { + let req = self.client.post(route).json(body); + // This call is what says the body is a credential. Nothing downstream + // can tell from the bytes, and a route list in the transport would have + // to be kept in step with endpoints declared here. + let (request_id, response) = self.client.send_suppressing_body(req).await?; + self.client.check_response(&request_id, response).await?; + Ok(()) + } + async fn submit_drop_table( &self, name: &str, namespace_path: &[String], ) -> Result<(String, Response)> { - let identifier = build_table_identifier(name, namespace_path, &self.client.id_delimiter); + let identifier = build_table_identifier(name, namespace_path)?; let cache_key = build_cache_key(name, namespace_path); let req = self.client.post(&format!("/v1/table/{}/drop/", identifier)); let (request_id, resp) = self.client.send(req).await?; @@ -364,8 +467,7 @@ impl RemoteDatabase { &self, request: &TableNamesRequest, ) -> Result<(Vec, ServerVersion)> { - let namespace_id = - build_namespace_identifier(&request.namespace_path, &self.client.id_delimiter); + let namespace_id = build_namespace_identifier(&request.namespace_path)?; let path = format!("/v1/namespace/{}/table/list", namespace_id); let mut names = Vec::new(); @@ -427,6 +529,7 @@ mod test_utils { namespace_headers: HashMap::new(), namespace_context_provider: None, tls_config: None, + sql_client: None, } } @@ -449,6 +552,7 @@ mod test_utils { namespace_headers: config.extra_headers.clone(), namespace_context_provider, tls_config: config.tls_config.clone(), + sql_client: None, } } } @@ -470,23 +574,93 @@ impl From<&CreateTableMode> for &'static str { } } -fn build_table_identifier(name: &str, namespace: &[String], delimiter: &str) -> String { - if !namespace.is_empty() { - let mut parts = namespace.to_vec(); - parts.push(name.to_string()); - parts.join(delimiter) - } else { - name.to_string() +/// The path segment addressing one object: its namespace path and its name. +/// +/// One builder for tables, Secrets, Functions and materialized views: the +/// identifier grammar belongs to the namespace spec, not to an object type. An +/// empty path addresses an object with no namespace. +/// +/// Components are checked for addressability, not a character set. The name's +/// grammar is the caller's, so a table reports [`Error::InvalidTableName`], a +/// Function admits names a table may not, and a catalog database carries the +/// `/` that [`RemoteCatalog`] allows. +/// +/// [`RemoteCatalog`]: super::catalog::RemoteCatalog +fn build_object_identifier(what: &str, name: &str, namespace: &[String]) -> Result { + for segment in namespace { + reject_unaddressable_component("namespace segment", segment)?; } + reject_unaddressable_component(what, name)?; + Ok(join_identifier( + namespace.iter().map(String::as_str).chain([name]), + )) } -fn build_namespace_identifier(namespace: &[String], delimiter: &str) -> String { +/// What a component may not be if the join is to survive being split back +/// apart: empty, a segment URL parsing resolves away, or the delimiter itself. +/// +/// Each erases a boundary no encoding of the joined form recovers. `["prod", +/// ""]` joins to `prod$`, which reads back as `["prod"]`, so a drop reaches the +/// parent of the namespace the caller named. +/// +/// Not a character set: per-component percent-encoding makes the wider set +/// safe, since a `/` in a name reaches the service as `%2F`, still one +/// segment. +fn reject_unaddressable_component(what: &str, value: &str) -> Result<()> { + if value.is_empty() { + return Err(Error::InvalidInput { + message: format!( + "{what} must not be empty: the identifier would carry two delimiters in a row, \ + and splitting it back apart would name a different object" + ), + }); + } + reject_relative_segment(what, value)?; + if value.contains(ID_DELIMITER) { + return Err(Error::InvalidInput { + message: format!( + "{what} '{value}' contains the identifier delimiter '{ID_DELIMITER}', so the \ + namespace path and the name it joins could not be told apart" + ), + }); + } + Ok(()) +} + +/// The path segment addressing one table. A wrapper for the error type: +/// callers match on [`Error::InvalidTableName`]. +fn build_table_identifier(name: &str, namespace: &[String]) -> Result { + validate_table_name(name)?; + build_object_identifier("table name", name, namespace) +} + +/// Join components into the `{id}` a route addresses: each percent-encoded, +/// then joined by the delimiter. +/// +/// Per component rather than over the joined string, so the delimiter stays a +/// delimiter and nothing inside a component can end the path segment. +/// +/// A second line, not the first: a component from the object charset is all +/// unreserved and encodes to itself, so the route reads as the caller wrote it. +/// It does not cover `.` and `..`, which are unreserved too and resolve away +/// after decoding -- [`build_object_identifier`] refuses those. +fn join_identifier<'a>(components: impl Iterator) -> String { + components + .map(|component| urlencoding::encode(component).into_owned()) + .collect::>() + .join(ID_DELIMITER) +} + +/// The path segment addressing one namespace. +fn build_namespace_identifier(namespace: &[String]) -> Result { + for segment in namespace { + reject_unaddressable_component("namespace segment", segment)?; + } if namespace.is_empty() { // According to the namespace spec, use delimiter to represent root namespace - delimiter.to_string() - } else { - namespace.join(delimiter) + return Ok(ID_DELIMITER.to_string()); } + Ok(join_identifier(namespace.iter().map(String::as_str))) } /// Build a secure cache key using length prefixes. @@ -533,6 +707,83 @@ struct RemoteListJobsResponse { page_token: Option, } +#[derive(serde::Deserialize)] +struct RemoteListedFunctionVersion { + definition: FunctionVersion, +} + +#[derive(serde::Deserialize)] +struct RemoteListFunctionsResponse { + #[serde(default)] + functions: Vec, + #[serde(default)] + page_token: Option, +} + +#[derive(serde::Deserialize)] +struct RemoteDropFunctionResponse { + dropped: bool, +} + +/// The create body: every field of [`FunctionRegistrationRequest`] except the +/// name, which is the path identifier. +/// +/// A struct rather than a literal listing the fields, so that the compiler +/// decides what reaches the service. A field the registration request grows is +/// a build error here until it is handled; a literal would simply not send it. +#[derive(serde::Serialize)] +struct RemoteCreateFunctionRequest<'a> { + artifact: &'a FunctionArtifactRequest, + signature: &'a FunctionSignature, + runtime: &'a PythonRuntimeSpec, + /// Absent when the Function binds nothing, so such a client sends what a + /// client without bindings sends. + /// + /// A service that does not know the field ignores it: registration + /// succeeds, the returned version carries no bindings, and the Function + /// fails at execution with the variable unset. [`ServerVersion`] is how + /// this codebase refuses a feature the service is too old for; it is held + /// per table, so gating a database-level call is follow-up work. + /// + /// [`ServerVersion`]: super::db::ServerVersion + #[serde(skip_serializing_if = "<[SecretBinding]>::is_empty")] + secret_bindings: &'a [SecretBinding], +} + +/// Create a Secret under a name the database does not yet hold. +/// +/// Declared separately from the alter request although the two are identical +/// today: they are different operations to the service -- one refuses an +/// existing name, the other requires it -- and either may grow a field the +/// other has no meaning for. +/// +/// The name and its namespace are the path identifier, so neither appears here. +#[derive(serde::Serialize)] +struct RemoteCreateSecretRequest<'a> { + value: &'a str, +} + +/// Replace the credential behind a Secret the database already holds. +#[derive(serde::Serialize)] +struct RemoteAlterSecretRequest<'a> { + value: &'a str, +} + +#[derive(serde::Deserialize)] +struct RemoteListSecretsResponse { + #[serde(default)] + secrets: Vec, + #[serde(default)] + page_token: Option, +} + +/// An object rather than a bare name so a later listing can carry a Secret's +/// type or last-updated time without breaking this one. +#[derive(serde::Deserialize)] +struct RemoteListedSecret { + name: String, +} + /// Bound on `list_jobs` page walking; a warning is logged when the listing /// is truncated at this many pages. const MAX_LIST_JOBS_PAGES: usize = 100; @@ -550,11 +801,137 @@ impl Database for RemoteDatabase { }) } + async fn create_materialized_view_async( + &self, + request: CreateMaterializedViewRequest, + ) -> Result { + let identifier = build_table_identifier(&request.name, &request.namespace_path)?; + let req = self + .client + .post(&format!("/v1/materialized_view/{identifier}/create")) + .json(&serde_json::json!({ + "query": request.query, + "with_no_data": request.with_no_data, + })); + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let status = response.status(); + let body = response.text().await.err_to_http(request_id.clone())?; + let job_id = extract_job_id(&body); + + if request.with_no_data { + return Ok(match job_id { + Some(job_id) => Job::new(Box::new(RemoteJob::new(self.client.clone(), job_id))), + None => Job::new_done(), + }); + } + if status != StatusCode::ACCEPTED { + return Err(Error::Http { + source: "materialized-view creation with data must return 202 Accepted".into(), + request_id, + status_code: Some(status), + }); + } + let job_id = job_id.ok_or_else(|| Error::Http { + source: "materialized-view creation response did not contain a valid job_id".into(), + request_id, + status_code: Some(status), + })?; + Ok(Job::new(Box::new(RemoteJob::new( + self.client.clone(), + job_id, + )))) + } + + async fn drop_materialized_view_async( + &self, + name: &str, + namespace_path: &[String], + ) -> Result { + let identifier = build_table_identifier(name, namespace_path)?; + let request = self + .client + .post(&format!("/v1/materialized_view/{identifier}/drop")); + let (request_id, response) = self.client.send(request).await?; + let response = self.client.check_response(&request_id, response).await?; + let status = response.status(); + let body = response.text().await.err_to_http(request_id.clone())?; + if status != StatusCode::ACCEPTED { + return Err(Error::Http { + source: "materialized-view drop must return 202 Accepted".into(), + request_id, + status_code: Some(status), + }); + } + let job_id = extract_job_id(&body).ok_or_else(|| Error::Http { + source: "materialized-view drop response did not contain a valid job_id".into(), + request_id, + status_code: Some(status), + })?; + self.table_cache + .remove(&build_cache_key(name, namespace_path)) + .await; + Ok(Job::new(Box::new(RemoteJob::new( + self.client.clone(), + job_id, + )))) + } + + async fn list_materialized_views(&self, namespace_path: &[String]) -> Result> { + #[derive(serde::Deserialize)] + struct ListMaterializedViewsResponse { + #[serde(default)] + views: Vec, + #[serde(default)] + page_token: Option, + } + + let namespace_id = build_namespace_identifier(namespace_path)?; + let path = format!("/v1/namespace/{namespace_id}/materialized_view/list"); + let mut views = Vec::new(); + let mut page_token: Option = None; + let mut seen_page_tokens = HashSet::new(); + loop { + let mut req = self.client.get(&path); + if let Some(token) = &page_token { + req = req.query(&[("page_token", token)]); + } + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let status = response.status(); + let response: ListMaterializedViewsResponse = + response.json().await.err_to_http(request_id.clone())?; + views.extend(response.views); + let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) + else { + break; + }; + if !seen_page_tokens.insert(next_page_token.clone()) { + return Err(Error::Http { + source: "Materialized-view listing response repeated a page_token".into(), + request_id, + status_code: Some(status), + }); + } + page_token = Some(next_page_token); + } + Ok(views) + } + async fn create_function_async( &self, request: FunctionRegistrationRequest, ) -> Result> { - let req = self.client.post("/v1/functions/create").json(&request); + let function_id = build_object_identifier("Function name", &request.name, &[])?; + let req = self + .client + .post(&format!("/v1/function/{function_id}/create")) + .json(&RemoteCreateFunctionRequest { + artifact: &request.artifact, + signature: &request.signature, + runtime: &request.runtime, + secret_bindings: &request.secret_bindings, + }); let (request_id, response) = self.client.send(req).await?; let response = self.client.check_response(&request_id, response).await?; let status = response.status(); @@ -571,11 +948,11 @@ impl Database for RemoteDatabase { } async fn get_function(&self, name: &str, version: &str) -> Result { + let function_id = build_object_identifier("Function name", name, &[])?; let req = self .client - .post("/v1/functions/describe") + .post(&format!("/v1/function/{function_id}/describe")) .json(&serde_json::json!({ - "name": name, "version": version, })); let (request_id, response) = self.client.send(req).await?; @@ -583,16 +960,153 @@ impl Database for RemoteDatabase { response.json().await.err_to_http(request_id) } - fn job(&self, job_id: &str) -> Result { - Ok(crate::job::Job::new(Box::new(super::job::RemoteJob::new( - self.client.clone(), - job_id.to_string(), - )))) + async fn list_functions(&self) -> Result> { + let namespace_id = build_namespace_identifier(&[])?; + let path = format!("/v1/namespace/{namespace_id}/function/list"); + let mut functions = Vec::new(); + let mut page_token: Option = None; + let mut seen_page_tokens = HashSet::new(); + loop { + let mut req = self + .client + .get(&path) + .query(&[("include_definition", true)]); + if let Some(token) = &page_token { + req = req.query(&[("page_token", token)]); + } + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let status = response.status(); + let response: RemoteListFunctionsResponse = + response.json().await.err_to_http(request_id.clone())?; + functions.extend( + response + .functions + .into_iter() + .map(|listed| listed.definition), + ); + let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) + else { + break; + }; + if !seen_page_tokens.insert(next_page_token.clone()) { + return Err(Error::Http { + source: "Function listing response repeated a page_token".into(), + request_id, + status_code: Some(status), + }); + } + page_token = Some(next_page_token); + } + Ok(functions) + } + + async fn drop_function(&self, name: &str, version: &str) -> Result { + let function_id = build_object_identifier("Function name", name, &[])?; + let req = self + .client + .post(&format!("/v1/function/{function_id}/drop")) + .json(&serde_json::json!({ + "version": version, + })); + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let response: RemoteDropFunctionResponse = response.json().await.err_to_http(request_id)?; + Ok(response.dropped) + } + + async fn create_secret( + &self, + name: &str, + value: &str, + namespace_path: &[String], + ) -> Result<()> { + let secret_id = build_object_identifier("Secret name", name, namespace_path)?; + self.post_secret_write( + &format!("/v1/secret/{secret_id}/create"), + &RemoteCreateSecretRequest { value }, + ) + .await + } + + async fn alter_secret(&self, name: &str, value: &str, namespace_path: &[String]) -> Result<()> { + let secret_id = build_object_identifier("Secret name", name, namespace_path)?; + self.post_secret_write( + &format!("/v1/secret/{secret_id}/alter"), + &RemoteAlterSecretRequest { value }, + ) + .await + } + + async fn list_secrets(&self, namespace_path: &[String]) -> Result> { + let namespace_id = build_namespace_identifier(namespace_path)?; + let path = format!("/v1/namespace/{namespace_id}/secret/list"); + let mut names = Vec::new(); + let mut page_token: Option = None; + let mut seen_page_tokens = HashSet::new(); + loop { + let mut req = self.client.get(&path); + if let Some(token) = &page_token { + req = req.query(&[("page_token", token)]); + } + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + let status = response.status(); + let response: RemoteListSecretsResponse = + response.json().await.err_to_http(request_id.clone())?; + names.extend(response.secrets.into_iter().map(|secret| secret.name)); + let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty()) + else { + break; + }; + if !seen_page_tokens.insert(next_page_token.clone()) { + return Err(Error::Http { + source: "Secret listing response repeated a page_token".into(), + request_id, + status_code: Some(status), + }); + } + page_token = Some(next_page_token); + } + Ok(names) + } + + async fn drop_secret(&self, name: &str, namespace_path: &[String]) -> Result<()> { + let secret_id = build_object_identifier("Secret name", name, namespace_path)?; + let req = self.client.post(&format!("/v1/secret/{secret_id}/drop")); + let (request_id, response) = self.client.send(req).await?; + self.client.check_response(&request_id, response).await?; + Ok(()) + } + + async fn describe_secret(&self, name: &str, namespace_path: &[String]) -> Result { + let secret_id = build_object_identifier("Secret name", name, namespace_path)?; + let req = self + .client + .post(&format!("/v1/secret/{secret_id}/describe")); + let (request_id, response) = self.client.send(req).await?; + let response = self.client.check_response(&request_id, response).await?; + response.json().await.err_to_http(request_id) + } + + async fn open_job(&self, job_id: &str) -> Result { + let handle = super::job::RemoteJob::new(self.client.clone(), job_id.to_string()); + match crate::job::JobHandle::describe(&handle).await { + Ok(description) => Ok(Job::opened(Box::new(handle), description)), + Err(Error::Http { + status_code: Some(StatusCode::NOT_FOUND), + .. + }) => Err(Error::JobNotFound { + job_id: job_id.to_string(), + }), + Err(err) => Err(err), + } } async fn list_jobs(&self) -> Result> { let mut out = Vec::new(); let mut page_token: Option = None; + let mut seen_page_tokens = HashSet::new(); for page in 0..MAX_LIST_JOBS_PAGES { let mut body = serde_json::json!({}); if let Some(token) = &page_token { @@ -601,7 +1115,8 @@ impl Database for RemoteDatabase { let req = self.client.post("/v1/jobs/list").json(&body); let (request_id, rsp) = self.client.send(req).await?; let rsp = self.client.check_response(&request_id, rsp).await?; - let body: RemoteListJobsResponse = rsp.json().await.err_to_http(request_id)?; + let status = rsp.status(); + let body: RemoteListJobsResponse = rsp.json().await.err_to_http(request_id.clone())?; out.extend(body.jobs.into_iter().map(|row| JobInfo { job_id: row.job_id, table: row.table, @@ -609,10 +1124,17 @@ impl Database for RemoteDatabase { state: job_state_to_client(&row.state), created_at_millis: row.created_at_millis, })); - page_token = body.page_token; - if page_token.is_none() { + let Some(next_page_token) = body.page_token.filter(|token| !token.is_empty()) else { break; + }; + if !seen_page_tokens.insert(next_page_token.clone()) { + return Err(Error::Http { + source: "Job listing response repeated a page_token".into(), + request_id, + status_code: Some(status), + }); } + page_token = Some(next_page_token); if page + 1 == MAX_LIST_JOBS_PAGES { log::warn!( "list_jobs truncated after {} pages ({} jobs)", @@ -624,31 +1146,6 @@ impl Database for RemoteDatabase { Ok(out) } - async fn get_job(&self, job_id: &str) -> Result> { - let req = self - .client - .post("/v1/jobs/describe") - .json(&serde_json::json!({ "job_id": job_id })); - let (request_id, rsp) = self.client.send(req).await?; - let rsp = match self.client.check_response(&request_id, rsp).await { - Ok(rsp) => rsp, - Err(Error::Http { - status_code: Some(StatusCode::NOT_FOUND), - .. - }) => return Ok(None), - Err(err) => return Err(err), - }; - let body: DescribeJobResponse = rsp.json().await.err_to_http(request_id)?; - Ok(Some(JobDescription { - job_id: body.job_id, - job_type: body.job_type, - state: job_state_to_client(&body.job_state), - creation_ms: body.creation_ms, - spec: body.spec, - failure: body.failure.map(|reported| reported.into_job_failure()), - })) - } - async fn cancel_job(&self, job_id: &str) -> Result { let req = self .client @@ -665,19 +1162,28 @@ impl Database for RemoteDatabase { } } - async fn job_history(&self, job_id: Option<&str>) -> Result> { - let mut body = serde_json::json!({}); - if let Some(job_id) = job_id { - body["job_id"] = serde_json::Value::String(job_id.to_string()); - } - let req = self.client.post("/v1/jobs/query_events").json(&body); - let (request_id, rsp) = self.client.send(req).await?; - let rsp = self.client.check_response(&request_id, rsp).await?; - let bytes = rsp.bytes().await.err_to_http(request_id)?; - let reader = arrow_ipc::reader::StreamReader::try_new(std::io::Cursor::new(bytes), None)?; - reader - .collect::, _>>() - .map_err(Into::into) + async fn execute_query_async( + &self, + query: &str, + default_namespace_path: &[String], + ) -> Result { + let client = self + .sql_client + .as_ref() + .ok_or_else(|| Error::NotSupported { + message: "SQL is unavailable for this remote database client".to_string(), + })?; + client.submit(query, default_namespace_path).await + } + + async fn describe_query(&self, query_id: uuid::Uuid) -> Result { + let client = self + .sql_client + .as_ref() + .ok_or_else(|| Error::NotSupported { + message: "SQL is unavailable for this remote database client".to_string(), + })?; + client.describe(query_id).await } async fn table_names(&self, request: TableNamesRequest) -> Result> { @@ -705,8 +1211,7 @@ impl Database for RemoteDatabase { }; for table in &tables { - let table_identifier = - build_table_identifier(table, &request.namespace_path, &self.client.id_delimiter); + let table_identifier = build_table_identifier(table, &request.namespace_path)?; let cache_key = build_cache_key(table, &request.namespace_path); let remote_table = Arc::new(RemoteTable::new( self.client.clone(), @@ -722,7 +1227,7 @@ impl Database for RemoteDatabase { async fn list_tables(&self, request: ListTablesRequest) -> Result { let namespace_parts = request.id.as_deref().unwrap_or(&[]); - let namespace_id = build_namespace_identifier(namespace_parts, &self.client.id_delimiter); + let namespace_id = build_namespace_identifier(namespace_parts)?; let mut req = self .client .get(&format!("/v1/namespace/{}/table/list", namespace_id)); @@ -742,8 +1247,7 @@ impl Database for RemoteDatabase { // Cache the tables for future use let namespace_vec = namespace_parts.to_vec(); for table in &response.tables { - let table_identifier = - build_table_identifier(table, &namespace_vec, &self.client.id_delimiter); + let table_identifier = build_table_identifier(table, &namespace_vec)?; let cache_key = build_cache_key(table, &namespace_vec); let remote_table = Arc::new(RemoteTable::new( self.client.clone(), @@ -761,11 +1265,7 @@ impl Database for RemoteDatabase { async fn create_table(&self, mut request: CreateTableRequest) -> Result> { let body = stream_as_body(request.data.scan_as_stream())?; - let identifier = build_table_identifier( - &request.name, - &request.namespace_path, - &self.client.id_delimiter, - ); + let identifier = build_table_identifier(&request.name, &request.namespace_path)?; let req = self .client .post(&format!("/v1/table/{}/create/", identifier)) @@ -817,11 +1317,7 @@ impl Database for RemoteDatabase { } let rsp = self.client.check_response(&request_id, rsp).await?; let version = parse_server_version(&request_id, &rsp)?; - let table_identifier = build_table_identifier( - &request.name, - &request.namespace_path, - &self.client.id_delimiter, - ); + let table_identifier = build_table_identifier(&request.name, &request.namespace_path)?; let cache_key = build_cache_key(&request.name, &request.namespace_path); let table = Arc::new(RemoteTable::new( self.client.clone(), @@ -836,11 +1332,8 @@ impl Database for RemoteDatabase { } async fn clone_table(&self, request: CloneTableRequest) -> Result> { - let table_identifier = build_table_identifier( - &request.target_table_name, - &request.target_namespace_path, - &self.client.id_delimiter, - ); + let table_identifier = + build_table_identifier(&request.target_table_name, &request.target_namespace_path)?; let remote_request = RemoteCloneTableRequest { source_location: request.source_uri, @@ -881,11 +1374,7 @@ impl Database for RemoteDatabase { } async fn open_table(&self, request: OpenTableRequest) -> Result> { - let identifier = build_table_identifier( - &request.name, - &request.namespace_path, - &self.client.id_delimiter, - ); + let identifier = build_table_identifier(&request.name, &request.namespace_path)?; let cache_key = build_cache_key(&request.name, &request.namespace_path); // We describe the table to confirm it exists before moving on. @@ -901,11 +1390,7 @@ impl Database for RemoteDatabase { let rsp = self.client.check_response(&request_id, rsp).await?; let version = parse_server_version(&request_id, &rsp)?; let describe_body = rsp.text().await.ok(); - let table_identifier = build_table_identifier( - &request.name, - &request.namespace_path, - &self.client.id_delimiter, - ); + let table_identifier = build_table_identifier(&request.name, &request.namespace_path)?; let table = Arc::new(RemoteTable::new( self.client.clone(), request.name.clone(), @@ -932,8 +1417,7 @@ impl Database for RemoteDatabase { cur_namespace_path: &[String], new_namespace_path: &[String], ) -> Result<()> { - let current_identifier = - build_table_identifier(current_name, cur_namespace_path, &self.client.id_delimiter); + let current_identifier = build_table_identifier(current_name, cur_namespace_path)?; let current_cache_key = build_cache_key(current_name, cur_namespace_path); let new_cache_key = build_cache_key(new_name, new_namespace_path); @@ -997,7 +1481,7 @@ impl Database for RemoteDatabase { request: ListNamespacesRequest, ) -> Result { let namespace_parts = request.id.as_deref().unwrap_or(&[]); - let namespace_id = build_namespace_identifier(namespace_parts, &self.client.id_delimiter); + let namespace_id = build_namespace_identifier(namespace_parts)?; let mut req = self .client .get(&format!("/v1/namespace/{}/list", namespace_id)); @@ -1019,7 +1503,7 @@ impl Database for RemoteDatabase { request: CreateNamespaceRequest, ) -> Result { let namespace_parts = request.id.as_deref().unwrap_or(&[]); - let namespace_id = build_namespace_identifier(namespace_parts, &self.client.id_delimiter); + let namespace_id = build_namespace_identifier(namespace_parts)?; let mut req = self .client .post(&format!("/v1/namespace/{}/create", namespace_id)); @@ -1034,7 +1518,7 @@ impl Database for RemoteDatabase { } let body = CreateNamespaceRequestBody { - mode: request.mode.as_ref().map(|m| format!("{:?}", m)), + mode: request.mode, properties: request.properties, }; @@ -1042,12 +1526,15 @@ impl Database for RemoteDatabase { let (request_id, resp) = self.client.send(req).await?; let resp = self.client.check_response(&request_id, resp).await?; + if resp.status() == StatusCode::NO_CONTENT { + return Ok(CreateNamespaceResponse::default()); + } resp.json().await.err_to_http(request_id) } async fn drop_namespace(&self, request: DropNamespaceRequest) -> Result { let namespace_parts = request.id.as_deref().unwrap_or(&[]); - let namespace_id = build_namespace_identifier(namespace_parts, &self.client.id_delimiter); + let namespace_id = build_namespace_identifier(namespace_parts)?; let mut req = self .client .post(&format!("/v1/namespace/{}/drop", namespace_id)); @@ -1062,14 +1549,17 @@ impl Database for RemoteDatabase { } let body = DropNamespaceRequestBody { - mode: request.mode.as_ref().map(|m| format!("{:?}", m)), - behavior: request.behavior.as_ref().map(|b| format!("{:?}", b)), + mode: request.mode, + behavior: request.behavior, }; req = req.json(&body); let (request_id, resp) = self.client.send(req).await?; let resp = self.client.check_response(&request_id, resp).await?; + if resp.status() == StatusCode::NO_CONTENT { + return Ok(DropNamespaceResponse::default()); + } resp.json().await.err_to_http(request_id) } @@ -1078,7 +1568,7 @@ impl Database for RemoteDatabase { request: DescribeNamespaceRequest, ) -> Result { let namespace_parts = request.id.as_deref().unwrap_or(&[]); - let namespace_id = build_namespace_identifier(namespace_parts, &self.client.id_delimiter); + let namespace_id = build_namespace_identifier(namespace_parts)?; let req = self .client .post(&format!("/v1/namespace/{}/describe", namespace_id)) @@ -1097,7 +1587,7 @@ impl Database for RemoteDatabase { async fn namespace_client(&self) -> Result> { // Create a RestNamespace pointing to the same remote host with the same authentication headers let mut builder = lance_namespace_impls::RestNamespaceBuilder::new(self.client.host()) - .delimiter(&self.client.id_delimiter) + .delimiter(ID_DELIMITER) .headers(self.namespace_headers.clone()); if let Some(context_provider) = &self.namespace_context_provider { @@ -1133,7 +1623,7 @@ impl Database for RemoteDatabase { let mut properties = HashMap::new(); properties.insert("uri".to_string(), self.client.host().to_string()); - properties.insert("delimiter".to_string(), self.client.id_delimiter.clone()); + properties.insert("delimiter".to_string(), ID_DELIMITER.to_string()); for (key, value) in &self.namespace_headers { properties.insert(format!("header.{}", key), value.clone()); } @@ -1192,9 +1682,12 @@ mod tests { use lance_namespace_impls::{DynamicContextProvider, OperationInfo}; use crate::connection::ConnectBuilder; + use crate::database::Database; + use crate::materialized_view::CreateMaterializedViewRequest; use crate::{ Connection, Error, database::CreateTableMode, + job::JobEventsRequest, remote::{ARROW_STREAM_CONTENT_TYPE, ClientConfig, HeaderProvider, JSON_CONTENT_TYPE}, }; @@ -1230,6 +1723,103 @@ mod tests { assert_eq!(key1, key6, "Same inputs should produce same cache key"); } + #[tokio::test] + async fn test_create_materialized_view_uses_item_route_and_job() { + let db = super::RemoteDatabase::new_mock(|request| { + assert_eq!(request.method(), "POST"); + assert_eq!( + request.url().path(), + "/v1/materialized_view/analytics$adults/create" + ); + let body = request + .body() + .and_then(reqwest::Body::as_bytes) + .and_then(|bytes| serde_json::from_slice::(bytes).ok()) + .unwrap(); + assert_eq!( + body["query"], + "SELECT age AS \"age\" FROM \"raw\".\"people\" WHERE age >= 18 LIMIT 10" + ); + assert_eq!(body["with_no_data"], false); + http::Response::builder() + .status(202) + .body(serde_json::json!({"job_id": "j1-mv-create"}).to_string()) + .unwrap() + }); + let job = db + .create_materialized_view_async(CreateMaterializedViewRequest { + name: "adults".into(), + namespace_path: vec!["analytics".into()], + query: "SELECT age AS \"age\" FROM \"raw\".\"people\" WHERE age >= 18 LIMIT 10" + .into(), + with_no_data: false, + }) + .await + .unwrap(); + assert_eq!(job.id(), Some("j1-mv-create")); + } + + #[tokio::test] + async fn test_drop_materialized_view_uses_item_route_and_job() { + let db = super::RemoteDatabase::new_mock(|request| { + assert_eq!(request.method(), "POST"); + assert_eq!( + request.url().path(), + "/v1/materialized_view/analytics$adults/drop" + ); + assert!(request.body().is_none()); + http::Response::builder() + .status(202) + .body(serde_json::json!({"job_id": "j1-mv-drop"}).to_string()) + .unwrap() + }); + let job = db + .drop_materialized_view_async("adults", &["analytics".into()]) + .await + .unwrap(); + assert_eq!(job.id(), Some("j1-mv-drop")); + } + + #[tokio::test] + async fn test_list_materialized_views_follows_empty_pages() { + let page = Arc::new(AtomicUsize::new(0)); + let db = super::RemoteDatabase::new_mock({ + let page = page.clone(); + move |request| { + assert_eq!(request.method(), "GET"); + assert_eq!( + request.url().path(), + "/v1/namespace/analytics/materialized_view/list" + ); + match page.fetch_add(1, Ordering::SeqCst) { + 0 => { + assert!(request.url().query().is_none()); + http::Response::builder() + .status(200) + .body( + serde_json::json!({"views": [], "page_token": "next"}).to_string(), + ) + .unwrap() + } + 1 => { + assert_eq!(request.url().query(), Some("page_token=next")); + http::Response::builder() + .status(200) + .body(serde_json::json!({"views": ["adults"]}).to_string()) + .unwrap() + } + _ => panic!("listing requested too many pages"), + } + } + }); + assert_eq!( + db.list_materialized_views(&["analytics".into()]) + .await + .unwrap(), + ["adults"] + ); + } + #[tokio::test] async fn test_retries() { // We'll record the request_id here, to check it matches the one in the error. @@ -1816,7 +2406,11 @@ mod tests { http::Response::builder() .status(200) - .body(r#"{"tables": ["ns1$ns2$table1", "ns1$ns2$table2"]}"#) + // A namespace listing names the tables in that namespace; the + // namespace is the route, not part of each name. The client + // joins the two itself to build each table's identifier, so a + // listing that repeated the namespace would be joined twice. + .body(r#"{"tables": ["table1", "table2"]}"#) .unwrap() }); let names = conn @@ -1825,7 +2419,7 @@ mod tests { .execute() .await .unwrap(); - assert_eq!(names, vec!["ns1$ns2$table1", "ns1$ns2$table2"]); + assert_eq!(names, vec!["table1", "table2"]); } #[tokio::test] @@ -2543,7 +3137,38 @@ mod tests { } #[tokio::test] - async fn test_get_job() { + async fn test_list_jobs_rejects_a_page_token_cycle() { + let requests = Arc::new(AtomicUsize::new(0)); + let seen = requests.clone(); + let conn = Connection::new_with_handler(move |request| { + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + match seen.fetch_add(1, Ordering::SeqCst) { + 0 => assert!(body.get("page_token").is_none()), + _ => assert_eq!(body["page_token"], "loop"), + } + http::Response::builder() + .status(200) + .body(r#"{"jobs": [], "page_token": "loop"}"#) + .unwrap() + }); + + let error = conn.list_jobs().await.unwrap_err(); + assert!( + matches!( + &error, + Error::Http { + status_code: Some(http::StatusCode::OK), + .. + } + ), + "got {error:?}" + ); + assert_eq!(requests.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn test_open_job() { let conn = Connection::new_with_handler(|request| { assert_eq!(request.method(), &reqwest::Method::POST); assert_eq!(request.url().path(), "/v1/jobs/describe"); @@ -2557,51 +3182,55 @@ mod tests { ) .unwrap() }); - let job = conn.get_job("job-1").await.unwrap().unwrap(); - assert_eq!(job.job_id, "job-1"); - assert_eq!(job.job_type, "create_index"); - assert_eq!(job.state, "failed"); - assert_eq!(job.creation_ms, 1000); - assert_eq!(job.spec["column"], "vec"); - let failure = job.failure.unwrap(); + // Opening populates the handle, so the accessors answer without a + // second round trip. + let job = conn.open_job("job-1").await.unwrap(); + assert_eq!(job.id(), Some("job-1")); + assert_eq!(job.job_type().as_deref(), Some("create_index")); + assert_eq!(job.state().as_deref(), Some("failed")); + assert_eq!(job.creation_ms(), Some(1000)); + assert_eq!(job.spec().unwrap()["column"], "vec"); + assert!(job.result().is_none()); + let failure = job.failure().unwrap(); assert_eq!(failure.phase.as_deref(), Some("execute")); assert_eq!(failure.message.as_deref(), Some("worker died")); assert_eq!(failure.retryable, Some(true)); } #[tokio::test] - async fn test_get_job_missing_is_none() { + async fn test_open_job_reports_the_terminal_result() { let conn = Connection::new_with_handler(|_| { - http::Response::builder() - .status(404) - .body("no such job") - .unwrap() - }); - assert!(conn.get_job("nope").await.unwrap().is_none()); - } - - #[tokio::test] - async fn test_cancel_job() { - let conn = Connection::new_with_handler(|request| { - assert_eq!(request.url().path(), "/v1/jobs/cancel"); http::Response::builder() .status(200) - .body(r#"{"job_id": "job-1"}"#) + .body( + r#"{"job_id": "job-1", "job_type": "refresh_column", "job_state": "DONE", "creation_ms": 1000, "result": {"rows_assigned": 1000000, "rows_failed": 0}}"#, + ) .unwrap() }); - assert!(conn.cancel_job("job-1").await.unwrap()); + let job = conn.open_job("job-1").await.unwrap(); + assert_eq!(job.state().as_deref(), Some("finished")); + let result = job.result().unwrap(); + assert_eq!(result["rows_assigned"], 1_000_000); + assert_eq!(result["rows_failed"], 0); + } + #[tokio::test] + async fn test_open_job_missing_fails() { let conn = Connection::new_with_handler(|_| { http::Response::builder() .status(404) .body("no such job") .unwrap() }); - assert!(!conn.cancel_job("nope").await.unwrap()); + let err = conn.open_job("nope").await.unwrap_err(); + assert!( + matches!(&err, Error::JobNotFound { job_id } if job_id == "nope"), + "{err:?}" + ); } #[tokio::test] - async fn test_job_history_parses_arrow_stream() { + async fn test_job_events_scope_to_that_job() { let schema = Arc::new(Schema::new(vec![Field::new( "state", DataType::Utf8, @@ -2610,29 +3239,439 @@ mod tests { let batch = RecordBatch::try_new( schema.clone(), vec![Arc::new(arrow_array::StringArray::from(vec![ - "created", "done", + "claim_complete", ]))], ) .unwrap(); - let mut body = Vec::new(); + let mut events = Vec::new(); { - let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut body, &schema).unwrap(); + let mut writer = + arrow_ipc::writer::StreamWriter::try_new(&mut events, &schema).unwrap(); writer.write(&batch).unwrap(); writer.finish().unwrap(); } let conn = Connection::new_with_handler(move |request| { - assert_eq!(request.url().path(), "/v1/jobs/query_events"); - let req_body: serde_json::Value = + let body: serde_json::Value = serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); - assert_eq!(req_body["job_id"], "job-1"); + if request.url().path() == "/v1/jobs/describe" { + return http::Response::builder() + .status(200) + .body( + r#"{"job_id": "job-1", "job_type": "refresh_column", "job_state": "IN_PROGRESS", "creation_ms": 1}"# + .as_bytes() + .to_vec(), + ) + .unwrap(); + } + assert_eq!(request.url().path(), "/v1/jobs/query_events"); + // The handle supplies job_id; the caller only narrows the query. + assert_eq!(body["job_id"], "job-1"); + assert_eq!(body["limit"], 500); + assert_eq!(body["filter"], "state = 'claim_complete'"); http::Response::builder() .status(200) - .body(body.clone()) + .body(events.clone()) .unwrap() }); - let batches = conn.job_history(Some("job-1")).await.unwrap(); + let job = conn.open_job("job-1").await.unwrap(); + let batches = job + .events( + JobEventsRequest::default() + .limit(500) + .filter("state = 'claim_complete'"), + ) + .await + .unwrap(); assert_eq!(batches.len(), 1); - assert_eq!(batches[0].num_rows(), 2); + assert_eq!(batches[0].num_rows(), 1); + } + + #[tokio::test] + async fn test_job_events_keep_the_schema_when_nothing_matches() { + let schema = Arc::new(Schema::new(vec![Field::new( + "state", + DataType::Utf8, + false, + )])); + let mut events = Vec::new(); + { + let mut writer = + arrow_ipc::writer::StreamWriter::try_new(&mut events, &schema).unwrap(); + writer.finish().unwrap(); + } + let conn = Connection::new_with_handler(move |request| { + if request.url().path() == "/v1/jobs/describe" { + return http::Response::builder() + .status(200) + .body( + r#"{"job_id": "job-1", "job_type": "refresh_column", "job_state": "IN_PROGRESS", "creation_ms": 1}"# + .as_bytes() + .to_vec(), + ) + .unwrap(); + } + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + // Only the job id when the caller narrows nothing. + assert_eq!(body, serde_json::json!({ "job_id": "job-1" })); + http::Response::builder() + .status(200) + .body(events.clone()) + .unwrap() + }); + let job = conn.open_job("job-1").await.unwrap(); + let batches = job.events(JobEventsRequest::default()).await.unwrap(); + assert_eq!(batches.len(), 1); + assert_eq!(batches[0].num_rows(), 0); + assert_eq!(batches[0].schema(), schema); + } + + /// A component that is not a legal Secret component never reaches a + /// transport. Before the identifier was checked here, each of these decided + /// the route instead of the name: `a/b` and `../jobs` left `/v1/secret/` + /// entirely, carrying a create body that holds a credential, and `a$b` read + /// as the namespace `a` and the name `b`. + #[tokio::test] + async fn test_an_illegal_component_never_reaches_the_transport() { + use std::sync::{Arc, Mutex}; + for name in [ + "../jobs", + "a/b", + "a$b", + "with space", + "q?x", + "a#b", + "a%2Fb", + "", + ] { + let reached = Arc::new(Mutex::new(false)); + let flag = reached.clone(); + let conn = Connection::new_with_handler(move |_| { + *flag.lock().unwrap() = true; + http::Response::builder().status(200).body("{}").unwrap() + }); + let error = conn + .create_secret(name, "sk-live-0001", &[]) + .await + .expect_err("an illegal component must be refused"); + assert!(!*reached.lock().unwrap(), "{name:?} reached the transport"); + assert!( + error.to_string().contains("Secret name"), + "{name:?}: {error}" + ); + } + } + + /// `.` and `..` pass the character set and still cannot address anything: + /// URL parsing resolves them as relative segments, and after + /// percent-decoding, so no spelling of either survives. + #[tokio::test] + async fn test_a_relative_segment_component_is_refused() { + use std::sync::{Arc, Mutex}; + // The percent-encoded spellings are caught a step earlier, by the + // character set: `%` is not a character a Secret component may hold. + // They reach the relative-segment rule only where there is no charset + // to catch them first -- see the Function case below. + for component in [".", ".."] { + let reached = Arc::new(Mutex::new(false)); + let flag = reached.clone(); + let conn = Connection::new_with_handler(move |_| { + *flag.lock().unwrap() = true; + http::Response::builder() + .status(200) + .body(r#"{"secrets":[]}"#) + .unwrap() + }); + let by_name = conn + .drop_secret(component, &[]) + .await + .expect_err("a dot-only name must be refused"); + assert!( + by_name.to_string().contains("relative path segments"), + "{by_name}" + ); + + let by_segment = conn + .list_secrets(&[component.to_string()]) + .await + .expect_err("a dot-only namespace segment must be refused"); + assert!( + by_segment.to_string().contains("relative path segments"), + "{by_segment}" + ); + assert!( + !*reached.lock().unwrap(), + "{component:?} reached the transport" + ); + } + } + + /// An identifier the service accepts is untouched by the encoding, so the + /// route reads like the table and Function routes beside it. + #[tokio::test] + async fn test_an_admissible_identifier_is_not_encoded() { + use std::sync::{Arc, Mutex}; + let seen = Arc::new(Mutex::new(String::new())); + let captured = seen.clone(); + let conn = Connection::new_with_handler(move |request| { + *captured.lock().unwrap() = request.url().path().to_string(); + http::Response::builder().status(200).body("{}").unwrap() + }); + conn.drop_secret( + "openai-prod.v1", + &["prod".to_string(), "vision_2".to_string()], + ) + .await + .unwrap(); + assert_eq!( + *seen.lock().unwrap(), + "/v1/secret/prod$vision_2$openai-prod.v1/drop" + ); + } + + /// A table name is joined into the route the same way a Secret's is, so the + /// same two failures are reachable: a dot-only name leaves the table route + /// entirely, and a name holding the delimiter is indistinguishable from a + /// namespace boundary. + #[tokio::test] + async fn test_a_table_name_cannot_choose_its_own_route() { + use std::sync::{Arc, Mutex}; + for name in ["..", ".", "../jobs", "a/b", "a$b"] { + let reached = Arc::new(Mutex::new(false)); + let flag = reached.clone(); + let conn = Connection::new_with_handler(move |_| { + *flag.lock().unwrap() = true; + http::Response::builder().status(200).body("{}").unwrap() + }); + let error = conn + .drop_table(name, &[]) + .await + .expect_err("an unaddressable table name must be refused"); + assert!(!*reached.lock().unwrap(), "{name:?} reached the transport"); + assert!(!error.to_string().is_empty(), "{name:?}"); + } + } + + /// The namespace half of the same join. + #[tokio::test] + async fn test_a_namespace_segment_cannot_choose_its_own_route() { + use std::sync::{Arc, Mutex}; + for segment in ["..", "a$b", ""] { + let reached = Arc::new(Mutex::new(false)); + let flag = reached.clone(); + let conn = Connection::new_with_handler(move |_| { + *flag.lock().unwrap() = true; + http::Response::builder().status(200).body("{}").unwrap() + }); + let error = conn + .drop_table("t", &[segment.to_string()]) + .await + .expect_err("an unaddressable namespace segment must be refused"); + assert!( + !*reached.lock().unwrap(), + "{segment:?} reached the transport" + ); + assert!(!error.to_string().is_empty(), "{segment:?}"); + } + } + + /// A segment outside the table charset still addresses one segment: the + /// service decides whether it may exist, and percent-encoding is what keeps + /// the question reaching the right route. A catalog database is named this + /// way. + #[tokio::test] + async fn test_a_namespace_segment_outside_the_charset_is_encoded_not_refused() { + use std::sync::{Arc, Mutex}; + let seen = Arc::new(Mutex::new(String::new())); + let path = seen.clone(); + let conn = Connection::new_with_handler(move |request| { + *path.lock().unwrap() = request.url().path().to_string(); + http::Response::builder().status(200).body("{}").unwrap() + }); + conn.drop_table("t", &["team/search".to_string()]) + .await + .unwrap(); + assert_eq!(*seen.lock().unwrap(), "/v1/table/team%2Fsearch$t/drop/"); + } + + /// A Function name is percent-encoded, which covers everything but the + /// relative segment: `..` is unreserved, so it survives encoding and is + /// then resolved away, posting a registration body to `/v1/create`. + #[tokio::test] + async fn test_a_relative_segment_function_name_is_refused() { + use std::sync::{Arc, Mutex}; + for name in [".", "..", "%2E%2E"] { + let reached = Arc::new(Mutex::new(false)); + let flag = reached.clone(); + let conn = Connection::new_with_handler(move |_| { + *flag.lock().unwrap() = true; + http::Response::builder().status(200).body("{}").unwrap() + }); + let error = conn + .drop_function(name, "fv_1") + .await + .expect_err("a dot-only Function name must be refused"); + assert!( + error.to_string().contains("relative path segments"), + "{error}" + ); + assert!(!*reached.lock().unwrap(), "{name:?} reached the transport"); + } + } + + /// Only `.` and `..` are relative segments. `...` and longer runs are + /// ordinary and address perfectly well, so refusing them would make an + /// object that works today stop working on upgrade. This pins that. + #[tokio::test] + async fn test_a_longer_run_of_periods_is_an_ordinary_name() { + use std::sync::{Arc, Mutex}; + for name in ["...", "....", "a.", ".a", "a..b"] { + let seen = Arc::new(Mutex::new(String::new())); + let captured = seen.clone(); + let conn = Connection::new_with_handler(move |request| { + *captured.lock().unwrap() = request.url().path().to_string(); + http::Response::builder().status(200).body("{}").unwrap() + }); + conn.drop_secret(name, &[]) + .await + .unwrap_or_else(|error| panic!("{name:?} must remain addressable: {error}")); + assert_eq!( + *seen.lock().unwrap(), + format!("/v1/secret/{name}/drop"), + "{name:?} did not reach its own route" + ); + } + } + + #[tokio::test] + async fn test_create_and_alter_secret_send_the_value_in_the_request_body() { + for (route, call) in [ + ("/v1/secret/openai-prod/create", true), + ("/v1/secret/openai-prod/alter", false), + ] { + let conn = Connection::new_with_handler(move |request| { + assert_eq!(request.method(), &reqwest::Method::POST); + assert_eq!(request.url().path(), route); + // Never a path segment or query parameter, which is what keeps + // it out of access logs and proxy traces. + assert!(request.url().query().is_none(), "{:?}", request.url()); + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + // The name is the path identifier, so the body is the value alone. + assert_eq!(body, serde_json::json!({ "value": "sk-live-0001" })); + http::Response::builder().status(200).body("{}").unwrap() + }); + if call { + conn.create_secret("openai-prod", "sk-live-0001", &[]) + .await + .unwrap(); + } else { + conn.alter_secret("openai-prod", "sk-live-0001", &[]) + .await + .unwrap(); + } + } + } + + #[tokio::test] + async fn test_list_secrets_walks_pages_and_returns_names_only() { + let conn = Connection::new_with_handler(|request| { + assert_eq!(request.method(), &reqwest::Method::GET); + assert_eq!(request.url().path(), "/v1/namespace/$/secret/list"); + let page = request + .url() + .query_pairs() + .find(|(key, _)| key == "page_token") + .map(|(_, value)| value.into_owned()); + let body = match page.as_deref() { + None => r#"{"secrets":[{"name":"openai-prod"}],"page_token":"p2"}"#, + Some("p2") => r#"{"secrets":[{"name":"hf-prod"}]}"#, + Some(other) => panic!("unexpected page token: {other}"), + }; + http::Response::builder().status(200).body(body).unwrap() + }); + assert_eq!( + conn.list_secrets(&[]).await.unwrap(), + vec!["openai-prod".to_string(), "hf-prod".to_string()] + ); + } + + /// A server that keeps handing back the same token would otherwise spin + /// forever. + #[tokio::test] + async fn test_list_secrets_rejects_a_repeated_page_token() { + let conn = Connection::new_with_handler(|_| { + http::Response::builder() + .status(200) + .body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#) + .unwrap() + }); + let error = conn.list_secrets(&[]).await.unwrap_err(); + assert!( + error.to_string().contains("repeated a page_token"), + "{error}" + ); + } + + #[tokio::test] + async fn test_drop_and_describe_address_the_secret_in_the_path() { + let conn = Connection::new_with_handler(|request| { + assert_eq!(request.url().path(), "/v1/secret/openai-prod/drop"); + // Nothing is left to say once the path names the Secret. + assert!(request.body().is_none(), "{:?}", request.body()); + http::Response::builder().status(200).body("{}").unwrap() + }); + conn.drop_secret("openai-prod", &[]).await.unwrap(); + + let conn = Connection::new_with_handler(|request| { + assert_eq!(request.url().path(), "/v1/secret/openai-prod/describe"); + assert!(request.body().is_none(), "{:?}", request.body()); + http::Response::builder() + .status(200) + .body(r#"{"name":"openai-prod","created_at_millis":1,"updated_at_millis":2}"#) + .unwrap() + }); + let info = conn.describe_secret("openai-prod", &[]).await.unwrap(); + assert_eq!(info.name, "openai-prod"); + } + + /// The namespace is part of the identifier the path addresses, joined with + /// the client's configured delimiter the way every other object's is. A + /// root Secret is therefore addressed by its bare name, and a namespaced + /// one by the joined path -- there is no body field either way. + #[tokio::test] + async fn test_a_namespace_path_is_addressed_in_the_path() { + let conn = Connection::new_with_handler(|request| { + assert_eq!( + request.url().path(), + "/v1/secret/prod$vision$openai-prod/drop" + ); + http::Response::builder().status(200).body("{}").unwrap() + }); + conn.drop_secret("openai-prod", &["prod".to_string(), "vision".to_string()]) + .await + .unwrap(); + + let conn = Connection::new_with_handler(|request| { + assert_eq!(request.url().path(), "/v1/secret/openai-prod/drop"); + http::Response::builder().status(200).body("{}").unwrap() + }); + conn.drop_secret("openai-prod", &[]).await.unwrap(); + + // Listing is namespace-scoped, so the namespace is the whole identifier. + let conn = Connection::new_with_handler(|request| { + assert_eq!( + request.url().path(), + "/v1/namespace/prod$vision/secret/list" + ); + http::Response::builder() + .status(200) + .body(r#"{"secrets":[]}"#) + .unwrap() + }); + conn.list_secrets(&["prod".to_string(), "vision".to_string()]) + .await + .unwrap(); } #[tokio::test] @@ -2642,9 +3681,10 @@ mod tests { ); const FUNCTION_JOB: &str = include_str!("../../tests/fixtures/first_class_functions/v1/remote_function_job.json"); - let expected: serde_json::Value = serde_json::from_str(REQUEST).unwrap(); + let mut expected: serde_json::Value = serde_json::from_str(REQUEST).unwrap(); + expected.as_object_mut().unwrap().remove("name"); let conn = Connection::new_with_handler(move |request| match request.url().path() { - "/v1/functions/create" => { + "/v1/function/normalize_score/create" => { assert_eq!(request.method(), &reqwest::Method::POST); let body: serde_json::Value = serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); @@ -2665,7 +3705,7 @@ mod tests { assert_eq!(job.id(), Some("job-function-1")); let version = job.wait().await.unwrap(); assert_eq!(version.name(), "embed"); - assert_eq!(version.version(), "fv_01K3EXACT"); + assert_eq!(version.version(), "1"); } #[tokio::test] @@ -2675,18 +3715,147 @@ mod tests { ); let conn = Connection::new_with_handler(|request| { assert_eq!(request.method(), &reqwest::Method::POST); - assert_eq!(request.url().path(), "/v1/functions/describe"); + assert_eq!(request.url().path(), "/v1/function/embed/describe"); let body: serde_json::Value = serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); - assert_eq!( - body, - serde_json::json!({"name": "embed", "version": "fv_01K3EXACT"}) - ); + assert_eq!(body, serde_json::json!({"version": "1"})); http::Response::builder().status(200).body(VERSION).unwrap() }); - let version = conn.get_function("embed", "fv_01K3EXACT").await.unwrap(); + let version = conn.get_function("embed", "1").await.unwrap(); assert_eq!(version.name(), "embed"); - assert_eq!(version.version(), "fv_01K3EXACT"); + assert_eq!(version.version(), "1"); + } + + #[tokio::test] + async fn test_list_functions_requests_definitions_and_paginates() { + const VERSION: &str = include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json" + ); + let version: serde_json::Value = serde_json::from_str(VERSION).unwrap(); + let page = Arc::new(AtomicUsize::new(0)); + let conn = Connection::new_with_handler(move |request| { + assert_eq!(request.method(), &reqwest::Method::GET); + assert_eq!(request.url().path(), "/v1/namespace/$/function/list"); + let query = request.url().query_pairs().collect::>(); + assert_eq!(query.get("include_definition").unwrap(), "true"); + match page.fetch_add(1, Ordering::SeqCst) { + 0 => { + assert!(!query.contains_key("page_token")); + http::Response::builder() + .status(200) + .body(r#"{"functions": [], "page_token": "next"}"#.to_string()) + .unwrap() + } + _ => { + assert_eq!(query.get("page_token").unwrap(), "next"); + http::Response::builder() + .status(200) + .body( + serde_json::json!({ + "functions": [{ + "name": "embed", + "version": "1", + "definition": version.clone(), + }], + }) + .to_string(), + ) + .unwrap() + } + } + }); + let functions = conn.list_functions().await.unwrap(); + assert_eq!(functions.len(), 1); + assert_eq!(functions[0].name(), "embed"); + assert_eq!(functions[0].version(), "1"); + } + + #[tokio::test] + async fn test_list_functions_stops_on_an_empty_page_token() { + let requests = Arc::new(AtomicUsize::new(0)); + let seen = requests.clone(); + let conn = Connection::new_with_handler(move |request| { + seen.fetch_add(1, Ordering::SeqCst); + assert_eq!(request.method(), &reqwest::Method::GET); + assert_eq!(request.url().path(), "/v1/namespace/$/function/list"); + let query = request.url().query_pairs().collect::>(); + assert_eq!(query.get("include_definition").unwrap(), "true"); + assert!(!query.contains_key("page_token")); + http::Response::builder() + .status(200) + .body(r#"{"functions": [], "page_token": ""}"#) + .unwrap() + }); + + let functions = conn.list_functions().await.unwrap(); + assert!(functions.is_empty()); + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_list_functions_rejects_a_page_token_cycle() { + let page = Arc::new(AtomicUsize::new(0)); + let requests = page.clone(); + let conn = Connection::new_with_handler(move |request| { + assert_eq!(request.method(), &reqwest::Method::GET); + assert_eq!(request.url().path(), "/v1/namespace/$/function/list"); + let query = request.url().query_pairs().collect::>(); + assert_eq!(query.get("include_definition").unwrap(), "true"); + let next_page_token = match page.fetch_add(1, Ordering::SeqCst) { + 0 => { + assert!(!query.contains_key("page_token")); + "one" + } + 1 => { + assert_eq!(query.get("page_token").unwrap(), "one"); + "two" + } + 2 => { + assert_eq!(query.get("page_token").unwrap(), "two"); + "one" + } + page => panic!("unexpected page: {page}"), + }; + http::Response::builder() + .status(200) + .body( + serde_json::json!({ + "functions": [], + "page_token": next_page_token, + }) + .to_string(), + ) + .unwrap() + }); + + let error = conn.list_functions().await.unwrap_err(); + assert!( + matches!( + &error, + Error::Http { + status_code: Some(http::StatusCode::OK), + .. + } + ), + "got {error:?}" + ); + assert_eq!(requests.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn test_drop_function_sends_exact_version_and_decodes_replay() { + let conn = Connection::new_with_handler(|request| { + assert_eq!(request.method(), &reqwest::Method::POST); + assert_eq!(request.url().path(), "/v1/function/embed/drop"); + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + assert_eq!(body, serde_json::json!({"version": "1"})); + http::Response::builder() + .status(200) + .body(r#"{"dropped":false}"#) + .unwrap() + }); + assert!(!conn.drop_function("embed", "1").await.unwrap()); } #[tokio::test] @@ -2695,7 +3864,9 @@ mod tests { let polls_ref = polls.clone(); let conn = Connection::new_with_handler(move |request| { assert_eq!(request.url().path(), "/v1/jobs/describe"); - let state = if polls_ref.fetch_add(1, Ordering::SeqCst) == 0 { + // Two in-progress answers: one for the load, one for the first + // status poll. + let state = if polls_ref.fetch_add(1, Ordering::SeqCst) < 2 { "IN_PROGRESS" } else { "DONE" @@ -2703,16 +3874,18 @@ mod tests { http::Response::builder() .status(200) .body(format!( - r#"{{"job_id": "job-1", "job_type": "create_function", "job_state": "{}", "creation_ms": 1, "result": {{"name": "embed", "version": "fv_1"}}}}"#, + r#"{{"job_id": "job-1", "job_type": "create_function", "job_state": "{}", "creation_ms": 1, "result": {{"name": "embed", "version": "1"}}}}"#, state )) .unwrap() }); - let job = conn.job("job-1").unwrap(); + let job = conn.open_job("job-1").await.unwrap(); assert_eq!(job.id(), Some("job-1")); + // Opening already answered the state; no extra call needed for it. + assert_eq!(job.state().as_deref(), Some("running")); assert_eq!(job.status().await.unwrap(), "running"); job.wait().await.unwrap(); assert_eq!(job.status().await.unwrap(), "finished"); - assert!(polls.load(Ordering::SeqCst) >= 3); + assert!(polls.load(Ordering::SeqCst) >= 4); } } diff --git a/rust/lancedb/src/remote/job.rs b/rust/lancedb/src/remote/job.rs index 0d41dbb35..80256421a 100644 --- a/rust/lancedb/src/remote/job.rs +++ b/rust/lancedb/src/remote/job.rs @@ -5,13 +5,15 @@ use std::time::Duration; +use arrow_array::RecordBatch; use async_trait::async_trait; use tokio::time::sleep; use serde::Deserialize; +use crate::database::JobDescription; use crate::error::{Error, JobFailure, Result}; -use crate::job::{JobHandle, TerminalResult}; +use crate::job::{JobEventsRequest, JobHandle, TerminalResult}; use crate::remote::client::{HttpSend, RequestResultExt, RestfulLanceDbClient}; /// Delay before the second job-state poll; doubles up to [`MAX_POLL_INTERVAL`]. @@ -86,7 +88,7 @@ pub(super) struct DescribeJobResponse { #[serde(default)] pub(super) spec: serde_json::Value, #[serde(default)] - result: Option, + pub(super) result: Option, #[serde(default)] pub(super) failure: Option, } @@ -110,6 +112,39 @@ impl DescribeJobResponse { fn into_terminal_result(self, request_id: String) -> TerminalResult { TerminalResult::remote(self.result, request_id) } + + /// The public description this wire envelope stands for. + pub(super) fn into_description(self) -> JobDescription { + JobDescription { + job_id: self.job_id, + job_type: self.job_type, + state: JobState::from(self.job_state.as_str()).client_label(), + creation_ms: self.creation_ms, + spec: self.spec, + result: self.result, + failure: self.failure.map(ReportedFailure::into_job_failure), + } + } +} + +/// One `/v1/jobs/query_events` round trip. +pub(super) async fn fetch_job_events( + client: &RestfulLanceDbClient, + body: serde_json::Value, +) -> Result> { + let request = client.post("/v1/jobs/query_events").json(&body); + let (request_id, response) = client.send(request).await?; + let response = client.check_response(&request_id, response).await?; + let bytes = response.bytes().await.err_to_http(request_id)?; + let reader = arrow_ipc::reader::StreamReader::try_new(std::io::Cursor::new(bytes), None)?; + let schema = reader.schema(); + let mut batches = reader.collect::, _>>()?; + // A query that matched nothing still describes the event columns. + // Keep that schema so callers can build a typed empty result. + if batches.is_empty() { + batches.push(RecordBatch::new_empty(schema)); + } + Ok(batches) } pub struct RemoteJob { @@ -123,7 +158,7 @@ impl RemoteJob { } /// One `/v1/jobs/describe` round trip. - async fn describe(&self) -> Result<(String, DescribeJobResponse)> { + async fn fetch_description(&self) -> Result<(String, DescribeJobResponse)> { let request = self .client .post("/v1/jobs/describe") @@ -148,13 +183,28 @@ impl JobHandle for RemoteJob { } async fn status(&self) -> Result { - Ok(self.describe().await?.1.state().client_label()) + Ok(self.fetch_description().await?.1.state().client_label()) + } + + async fn describe(&self) -> Result { + Ok(self.fetch_description().await?.1.into_description()) + } + + async fn events(&self, request: JobEventsRequest) -> Result> { + let mut body = serde_json::json!({ "job_id": self.job_id }); + if let Some(limit) = request.limit { + body["limit"] = serde_json::Value::from(limit); + } + if let Some(filter) = request.filter { + body["filter"] = serde_json::Value::String(filter); + } + fetch_job_events(&self.client, body).await } async fn wait(&self) -> Result { let mut interval = INITIAL_POLL_INTERVAL; loop { - let (request_id, description) = self.describe().await?; + let (request_id, description) = self.fetch_description().await?; match description.state() { JobState::Done => return Ok(description.into_terminal_result(request_id)), JobState::Failed => { @@ -235,7 +285,7 @@ mod tests { async fn typed_remote_job_fixtures_decode_terminal_results() { let function = Job::::new_typed(Box::new(FixtureRemoteJob(FUNCTION_JOB))); let result = function.wait().await.expect("typed FunctionVersion result"); - assert_eq!(result.version(), "fv_01K3EXACT"); + assert_eq!(result.version(), "1"); let refresh = Job::::new_typed(Box::new(FixtureRemoteJob(REFRESH_JOB))); diff --git a/rust/lancedb/src/remote/oauth.rs b/rust/lancedb/src/remote/oauth.rs index fd61db919..c7cffa327 100644 --- a/rust/lancedb/src/remote/oauth.rs +++ b/rust/lancedb/src/remote/oauth.rs @@ -1,25 +1,162 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors +//! OAuth authentication for LanceDB Cloud connections. +//! +//! Protocol mechanics (authorization URL and CSRF state, PKCE, token +//! exchanges, refresh, device authorization and polling, standard response +//! parsing, and token-endpoint client authentication) are delegated to the +//! [`oauth2`] crate. LanceDB owns the orchestration: OIDC discovery, endpoint +//! validation, the loopback callback server, browser and terminal +//! interaction, timeouts, token caching, and the Azure managed-identity +//! (IMDS) flow. + +use std::borrow::Cow; use std::collections::HashMap; -use std::net::IpAddr; +use std::net::{IpAddr, SocketAddr}; +use std::pin::Pin; +use std::process::Command; use std::sync::Arc; use std::time::{Duration, Instant}; use async_trait::async_trait; -use log::debug; +use log::{debug, warn}; +use oauth2::basic::BasicTokenType; +use oauth2::http::{Method, StatusCode}; +use oauth2::{ + AccessToken, AuthType, AuthUrl, ClientId, ClientSecret, CsrfToken, DeviceAuthorizationUrl, + DeviceCodeErrorResponseType, EndpointNotSet, EndpointSet, HttpRequest, HttpResponse, + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, + StandardDeviceAuthorizationResponse, StandardTokenIntrospectionResponse, TokenUrl, +}; use reqwest::Client; use serde::Deserialize; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; use tokio::sync::RwLock; +use tokio::time::Instant as TokioInstant; +use url::Url; use crate::error::{Error, Result}; use crate::remote::client::HeaderProvider; const DEFAULT_REFRESH_BUFFER_SECS: u64 = 300; const DEFAULT_TOKEN_TTL_SECS: u64 = 3600; +const DEFAULT_CALLBACK_PORT: u16 = 8400; +const AUTHORIZATION_CALLBACK_TIMEOUT_SECS: u64 = 300; const AZURE_IMDS_ENDPOINT: &str = "http://169.254.169.254/metadata/identity/oauth2/token"; const AZURE_IMDS_API_VERSION: &str = "2018-02-01"; +fn oauth_url_uses_secure_transport(url: &Url) -> bool { + url.scheme() == "https" + || (url.scheme() == "http" + && match url.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + }) +} + +fn validate_oauth_url(value: &str, name: &str) -> Result { + let url = Url::parse(value).map_err(|e| Error::InvalidInput { + message: format!("Invalid OAuth {name}: {e}"), + })?; + if oauth_url_uses_secure_transport(&url) { + Ok(url) + } else { + Err(Error::InvalidInput { + message: format!("OAuth {name} must use https, except for http on a loopback host"), + }) + } +} + +fn authorization_prompt(url: &Url) -> String { + format!("Open this URL to authenticate with OAuth: {url}") +} + +fn device_prompt(verification_uri: &str, user_code: &str) -> String { + format!("To authenticate with OAuth, visit {verification_uri} and enter code {user_code}") +} + +fn write_oauth_prompt(mut output: impl std::io::Write, prompt: &str) { + let _ = writeln!(output, "{prompt}"); +} + +fn show_oauth_prompt(prompt: &str) { + write_oauth_prompt(std::io::stderr().lock(), prompt); +} + +/// Options for the interactive OAuth Authorization Code flow. +/// +/// The built-in callback server accepts only loopback HTTP redirect URIs. PKCE +/// with the S256 challenge method is enabled by default and should be disabled +/// only for providers that do not support it. +/// +/// # Example +/// +/// ``` +/// use lancedb::remote::{AuthorizationCodeOptions, OAuthFlow}; +/// +/// let flow = OAuthFlow::AuthorizationCode( +/// AuthorizationCodeOptions::new() +/// .callback_port(8400) +/// .use_pkce(true), +/// ); +/// ``` +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AuthorizationCodeOptions { + /// Redirect URI registered with the identity provider. + /// + /// Defaults to `http://127.0.0.1:{callback_port}/callback`. + pub redirect_uri: Option, + + /// Port for the built-in loopback callback server. + /// + /// Defaults to 8400. When `redirect_uri` contains an explicit port, this + /// option must either be omitted or match that port. + pub callback_port: Option, + + /// Whether to protect the authorization code exchange with S256 PKCE. + pub use_pkce: bool, +} + +impl Default for AuthorizationCodeOptions { + fn default() -> Self { + Self { + redirect_uri: None, + callback_port: None, + use_pkce: true, + } + } +} + +impl AuthorizationCodeOptions { + /// Create authorization-code options with S256 PKCE enabled. + pub fn new() -> Self { + Self::default() + } + + /// Set the loopback redirect URI registered with the identity provider. + pub fn redirect_uri(mut self, redirect_uri: impl Into) -> Self { + self.redirect_uri = Some(redirect_uri.into()); + self + } + + /// Set the port for the built-in loopback callback server. + pub fn callback_port(mut self, callback_port: u16) -> Self { + self.callback_port = Some(callback_port); + self + } + + /// Enable or disable S256 PKCE. + pub fn use_pkce(mut self, use_pkce: bool) -> Self { + self.use_pkce = use_pkce; + self + } +} + /// OAuth authentication flow configuration. #[derive(Debug, Clone)] pub enum OAuthFlow { @@ -27,6 +164,15 @@ pub enum OAuthFlow { /// Requires `client_secret` in [`OAuthConfig`]. ClientCredentials, + /// Authorization Code grant using an interactive browser and a built-in + /// loopback callback server. The authorization URL is also written to + /// stderr so it remains available when the browser cannot be opened. + AuthorizationCode(AuthorizationCodeOptions), + + /// Device Authorization grant for CLI and headless environments. The + /// verification URI and user code are written to stderr. + DeviceCode, + /// Azure Managed Identity via IMDS. /// Works on Azure VMs, AKS, App Service, and Azure Functions. /// IMDS requests bypass proxy settings because the endpoint is link-local. @@ -37,6 +183,90 @@ pub enum OAuthFlow { }, } +/// How the client authenticates to the OAuth token endpoint. +/// +/// The method applies to every OAuth request that carries client +/// authentication: client-credentials, authorization-code exchange, +/// refresh-token, and device-authorization requests. The Azure managed +/// identity flow ignores this option because it uses its own IMDS protocol. +/// +/// The default (`None` in [`OAuthConfig`]) resolves to +/// [`ClientAuthMethod::ClientSecretBasic`] when a `client_secret` is +/// configured, matching [RFC 6749 section 2.3.1](https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1) +/// and the default configuration of Okta confidential applications, and to +/// [`ClientAuthMethod::None`] for public clients (no secret), such as +/// authorization-code-with-PKCE or typical device applications. +/// +/// # Example +/// +/// ``` +/// use lancedb::remote::{AuthorizationCodeOptions, ClientAuthMethod, OAuthConfig, OAuthFlow}; +/// +/// let config = OAuthConfig { +/// issuer_url: "https://idp.example.com".to_string(), +/// client_id: "client-id".to_string(), +/// client_secret: Some("secret".to_string()), +/// client_auth_method: Some(ClientAuthMethod::ClientSecretPost), +/// scopes: vec!["openid".to_string()], +/// resource: None, +/// audience: None, +/// flow: OAuthFlow::AuthorizationCode(AuthorizationCodeOptions::new()), +/// refresh_buffer_secs: None, +/// token_cache: None, +/// }; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientAuthMethod { + /// No client authentication (`none`). For public clients such as + /// browser/CLI applications using PKCE or the device flow. Requires that + /// no `client_secret` is configured. + None, + + /// HTTP Basic authentication (`client_secret_basic`), the RFC 6749 + /// recommended method and the normal default for confidential clients, + /// including default Okta applications. Requires a `client_secret`. + ClientSecretBasic, + + /// Credentials in the request body (`client_secret_post`). Some + /// providers are configured to require this method. Requires a + /// `client_secret`. + ClientSecretPost, +} + +impl ClientAuthMethod { + fn auth_type(self) -> AuthType { + match self { + // Without a secret the crate always falls back to sending the + // client_id in the request body, which is the desired behavior + // for public clients. + Self::None | Self::ClientSecretPost => AuthType::RequestBody, + Self::ClientSecretBasic => AuthType::BasicAuth, + } + } +} + +fn resolve_client_auth_method( + method: Option, + client_secret: Option<&str>, +) -> Result { + match (method, client_secret) { + (Some(ClientAuthMethod::None), Some(_)) => Err(Error::InvalidInput { + message: "client_auth_method None cannot be combined with client_secret".to_string(), + }), + ( + Some( + method @ (ClientAuthMethod::ClientSecretBasic | ClientAuthMethod::ClientSecretPost), + ), + None, + ) => Err(Error::InvalidInput { + message: format!("client_auth_method {method:?} requires client_secret to be set"), + }), + (Some(method), _) => Ok(method), + (None, Some(_)) => Ok(ClientAuthMethod::ClientSecretBasic), + (None, None) => Ok(ClientAuthMethod::None), + } +} + /// OAuth configuration for LanceDB authentication. /// /// All token acquisition and refresh is handled in the Rust layer. @@ -58,13 +288,37 @@ pub struct OAuthConfig { /// For example: `["api://{app_id}/.default"]` pub scopes: Vec, + /// Resource indicator sent to the authorization and token endpoints (RFC 8707). + /// The value is forwarded verbatim, including on refresh requests, and must + /// be an absolute URI without a fragment. + /// Not supported for Azure managed identity. + pub resource: Option, + + /// Provider-specific audience sent to the authorization and token endpoints, + /// including refresh requests. + /// Not supported for Azure managed identity. + pub audience: Option, + /// Authentication flow to use. pub flow: OAuthFlow, + /// How the client authenticates to the token endpoint. See + /// [`ClientAuthMethod`] for the resolution rules that apply when this is + /// `None` (the default). + pub client_auth_method: Option, + /// Seconds before token expiry to trigger proactive refresh (default: 300). /// Keep this well below the token TTL; if it is greater than or equal to /// the TTL, each request refreshes the token. pub refresh_buffer_secs: Option, + + /// Opt in to the persistent token cache so short-lived processes can + /// reuse an authenticated session instead of re-prompting. + /// + /// When unset (the default), tokens stay in process memory only. Only + /// refresh tokens are persisted; see + /// [`TokenCacheOptions`](crate::remote::TokenCacheOptions). + pub token_cache: Option, } impl std::fmt::Debug for OAuthConfig { @@ -77,8 +331,12 @@ impl std::fmt::Debug for OAuthConfig { &self.client_secret.as_deref().map(|_| ""), ) .field("scopes", &self.scopes) + .field("resource", &self.resource) + .field("audience", &self.audience) .field("flow", &self.flow) + .field("client_auth_method", &self.client_auth_method) .field("refresh_buffer_secs", &self.refresh_buffer_secs) + .field("token_cache", &self.token_cache) .finish() } } @@ -88,26 +346,87 @@ impl std::fmt::Debug for OAuthConfig { #[derive(Clone, Debug, Deserialize)] struct OidcDiscovery { token_endpoint: String, + authorization_endpoint: Option, + device_authorization_endpoint: Option, } // -- Token Response -- +/// A token endpoint success response. +/// +/// This implements [`oauth2::TokenResponse`] so the `oauth2` crate can parse +/// provider responses directly, while keeping LanceDB's lenient field +/// handling: `expires_in` may be an integer, an integer-valued float, or a +/// numeric string, and `token_type` is optional. #[derive(Deserialize)] -struct TokenResponse { - access_token: String, +pub(crate) struct TokenResponse { + pub(crate) access_token: AccessToken, + #[serde(default)] + pub(crate) refresh_token: Option, /// Token lifetime in seconds. /// Some providers (Azure IMDS) return this as a string, so we accept both. #[serde(default, deserialize_with = "deserialize_optional_u64_or_string")] - expires_in: Option, + pub(crate) expires_in: Option, #[serde(default)] - #[allow(dead_code)] - token_type: Option, + pub(crate) token_type: Option, +} + +const BEARER: BasicTokenType = BasicTokenType::Bearer; + +impl oauth2::TokenResponse for TokenResponse { + type TokenType = BasicTokenType; + + fn access_token(&self) -> &AccessToken { + &self.access_token + } + + fn token_type(&self) -> &BasicTokenType { + self.token_type.as_ref().unwrap_or(&BEARER) + } + + fn expires_in(&self) -> Option { + self.expires_in.map(Duration::from_secs) + } + + fn refresh_token(&self) -> Option<&RefreshToken> { + self.refresh_token.as_ref() + } + + fn scopes(&self) -> Option<&Vec> { + None + } +} + +// The oauth2::TokenResponse trait requires Serialize; LanceDB never +// serializes token responses, so redact every credential-bearing field rather +// than risk leaking one through an accidental serialization. +impl serde::Serialize for TokenResponse { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct("TokenResponse", 4)?; + state.serialize_field("access_token", "")?; + state.serialize_field( + "refresh_token", + &self.refresh_token.as_ref().map(|_| ""), + )?; + state.serialize_field("expires_in", &self.expires_in)?; + state.serialize_field("token_type", &self.token_type)?; + state.end() + } } impl std::fmt::Debug for TokenResponse { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TokenResponse") .field("access_token", &"") + .field( + "refresh_token", + &self.refresh_token.as_ref().map(|_| ""), + ) .field("expires_in", &self.expires_in) .field("token_type", &self.token_type) .finish() @@ -168,6 +487,7 @@ where struct TokenState { access_token: Option, + refresh_token: Option, expires_at: Option, } @@ -175,6 +495,7 @@ impl TokenState { fn new() -> Self { Self { access_token: None, + refresh_token: None, expires_at: None, } } @@ -188,51 +509,270 @@ impl TokenState { } fn update(&mut self, resp: &TokenResponse) { - self.access_token = Some(resp.access_token.clone()); + self.access_token = Some(resp.access_token.secret().clone()); + if let Some(token) = resp.refresh_token.as_ref() { + self.refresh_token = Some(token.secret().clone()); + } let expires_in = resp.expires_in.unwrap_or(DEFAULT_TOKEN_TTL_SECS); self.expires_at = Some(Instant::now() + Duration::from_secs(expires_in)); } } #[async_trait] -trait TokenSource: Send + Sync + std::fmt::Debug { +pub(crate) trait TokenSource: Send + Sync + std::fmt::Debug { async fn fetch_token(&self) -> Result; + + async fn refresh_token(&self, _refresh_token: &str) -> Result { + Ok(RefreshResult::Unsupported) + } } -struct ClientCredentialsSource { +#[derive(Debug)] +pub(crate) enum RefreshResult { + Refreshed(TokenResponse), + Reauthenticate, + Unsupported, +} + +// -- OAuth HTTP transport -- + +/// Errors raised by [`OAuthHttpClient`]. +#[derive(Debug)] +enum OAuthHttpError { + /// The request could not be built (invalid method, URL, or headers). + Build(String), + /// The request failed at the transport layer. This includes redirects + /// rejected by the hardened client redirect policy. + Transport(reqwest::Error), + /// The server reported a transient condition: HTTP 429, a 5xx status, or + /// an OAuth `temporarily_unavailable` error. The device-code poll loop + /// treats these as retryable; single-shot requests surface them as errors. + Transient(StatusCode), +} + +impl std::fmt::Display for OAuthHttpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Build(message) => write!(f, "could not build OAuth request: {message}"), + Self::Transport(error) => { + write!(f, "OAuth HTTP request failed: {error}")?; + // Include the underlying cause (e.g. a redirect rejected by + // the hardened client policy) without ever including bodies. + if let Some(source) = std::error::Error::source(error) { + write!(f, ": {source}")?; + } + Ok(()) + } + Self::Transient(status) => { + write!(f, "OAuth server returned a transient response ({status})") + } + } + } +} + +impl std::error::Error for OAuthHttpError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Transport(error) => Some(error), + _ => None, + } + } +} + +#[derive(Clone)] +struct OAuthHttpClient { + inner: Client, +} + +impl OAuthHttpClient { + fn is_retryable_status_or_body(status: StatusCode, body: &[u8]) -> bool { + if status.as_u16() == 429 || status.is_server_error() { + return true; + } + serde_json::from_slice::(body) + .map(|error| error.error == "temporarily_unavailable") + .unwrap_or(false) + } +} + +impl<'c> oauth2::AsyncHttpClient<'c> for OAuthHttpClient { + type Error = OAuthHttpError; + type Future = Pin< + Box< + dyn Future> + + Send + + Sync + + 'c, + >, + >; + + fn call(&'c self, request: HttpRequest) -> Self::Future { + Box::pin(async move { + let (parts, body) = request.into_parts(); + let method = Method::from_bytes(parts.method.as_str().as_bytes()) + .map_err(|e| OAuthHttpError::Build(e.to_string()))?; + let url: Url = parts + .uri + .to_string() + .parse() + .map_err(|e| OAuthHttpError::Build(format!("invalid request URL: {e}")))?; + + let response = self + .inner + .request(method, url) + .headers(parts.headers) + .body(body) + .send() + .await + .map_err(OAuthHttpError::Transport)?; + + let status = response.status(); + let mut builder = oauth2::http::Response::builder().status(status); + for (name, value) in response.headers().iter() { + builder = builder.header(name, value); + } + let body = response + .bytes() + .await + .map_err(OAuthHttpError::Transport)? + .to_vec(); + let response = builder + .body(body) + .map_err(|e| OAuthHttpError::Build(e.to_string()))?; + + if !status.is_success() && Self::is_retryable_status_or_body(status, response.body()) { + debug!("OAuth token endpoint returned a transient response ({status})"); + return Err(OAuthHttpError::Transient(status)); + } + Ok(response) + }) + } +} + +// -- OAuth client construction -- + +type OauthClient = oauth2::Client< + oauth2::basic::BasicErrorResponse, + TokenResponse, + StandardTokenIntrospectionResponse, + oauth2::StandardRevocableToken, + oauth2::basic::BasicErrorResponse, + HasAuthUrl, + HasDeviceAuthUrl, + EndpointNotSet, + EndpointNotSet, + HasTokenUrl, +>; + +type BaseOauthClient = OauthClient; + +type TokenEndpointClient = OauthClient; + +fn token_error_context(context: &str, response: &T) -> String { + // StandardErrorResponse's Display renders only the provider's error code, + // description, and error URI; it never includes credential material. + format!("{context} failed: {response}") +} + +fn map_token_error( + error: RequestTokenError, + context: &str, +) -> Error { + match error { + RequestTokenError::ServerResponse(response) => Error::Runtime { + message: token_error_context(context, &response), + }, + RequestTokenError::Request(error) => Error::Runtime { + message: format!("{context} failed: {error}"), + }, + // Never include the raw body: it may contain credential material. + RequestTokenError::Parse(error, _) => Error::Runtime { + message: format!("{context} response could not be parsed: {error}"), + }, + RequestTokenError::Other(message) => Error::Runtime { + message: format!("{context} failed: {message}"), + }, + } +} + +fn map_device_token_error( + error: RequestTokenError, +) -> Error { + match error { + RequestTokenError::ServerResponse(response) => match response.error() { + DeviceCodeErrorResponseType::AccessDenied => Error::Runtime { + message: "Device authorization was denied by the user".to_string(), + }, + DeviceCodeErrorResponseType::ExpiredToken => Error::Runtime { + message: "Device authorization expired before authentication completed".to_string(), + }, + _ => Error::Runtime { + message: token_error_context("Device token request", &response), + }, + }, + RequestTokenError::Request(error) => Error::Runtime { + message: format!("Device token request failed: {error}"), + }, + RequestTokenError::Parse(error, _) => Error::Runtime { + message: format!("Device token response could not be parsed: {error}"), + }, + RequestTokenError::Other(message) => Error::Runtime { + message: format!("Device token request failed: {message}"), + }, + } +} + +struct OidcClient { issuer_url: String, client_id: String, - client_secret: String, + client_secret: Option, + client_auth_method: ClientAuthMethod, scopes: Vec, - http_client: Client, + resource: Option, + audience: Option, + http_client: OAuthHttpClient, discovery: RwLock>, } -impl std::fmt::Debug for ClientCredentialsSource { +impl std::fmt::Debug for OidcClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ClientCredentialsSource") + f.debug_struct("OidcClient") .field("issuer_url", &self.issuer_url) .field("client_id", &self.client_id) - .field("client_secret", &"") + .field( + "client_secret", + &self.client_secret.as_ref().map(|_| ""), + ) + .field("client_auth_method", &self.client_auth_method) .field("scopes", &self.scopes) + .field("resource", &self.resource) + .field("audience", &self.audience) .finish() } } -impl ClientCredentialsSource { +impl OidcClient { fn new( issuer_url: String, client_id: String, client_secret: Option, + client_auth_method: ClientAuthMethod, scopes: Vec, + resource: Option, + audience: Option, ) -> Result { - let client_secret = client_secret.ok_or(Error::InvalidInput { - message: "client_secret is required for ClientCredentials flow".to_string(), - })?; Self::validate_issuer_transport(&issuer_url)?; let http_client = Client::builder() .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if oauth_url_uses_secure_transport(attempt.url()) { + attempt.follow() + } else { + attempt + .error("OAuth redirects must use https, except for http on a loopback host") + } + })) .build() .map_err(|e| Error::Runtime { message: format!("Failed to create HTTP client for OAuth: {e}"), @@ -242,38 +782,17 @@ impl ClientCredentialsSource { issuer_url, client_id, client_secret, + client_auth_method, scopes, - http_client, + resource, + audience, + http_client: OAuthHttpClient { inner: http_client }, discovery: RwLock::new(None), }) } fn validate_issuer_transport(issuer_url: &str) -> Result<()> { - let issuer = url::Url::parse(issuer_url).map_err(|e| Error::InvalidInput { - message: format!("Invalid OAuth issuer_url: {e}"), - })?; - - match issuer.scheme() { - "https" => Ok(()), - "http" if Self::is_loopback_issuer(&issuer) => Ok(()), - _ => Err(Error::InvalidInput { - message: - "ClientCredentials OAuth issuer_url must use https, except for loopback hosts" - .to_string(), - }), - } - } - - fn is_loopback_issuer(issuer: &url::Url) -> bool { - let Some(host) = issuer.host_str() else { - return false; - }; - - host.eq_ignore_ascii_case("localhost") - || host - .parse::() - .map(|addr| addr.is_loopback()) - .unwrap_or(false) + validate_oauth_url(issuer_url, "issuer_url").map(drop) } async fn get_discovery(&self) -> Result { @@ -299,6 +818,7 @@ impl ClientCredentialsSource { let resp = self .http_client + .inner .get(&discovery_url) .send() .await @@ -319,6 +839,13 @@ impl ClientCredentialsSource { let disc: OidcDiscovery = resp.json().await.map_err(|e| Error::Runtime { message: format!("Failed to parse OIDC discovery document: {e}"), })?; + validate_oauth_url(&disc.token_endpoint, "token_endpoint")?; + if let Some(endpoint) = disc.authorization_endpoint.as_deref() { + validate_oauth_url(endpoint, "authorization_endpoint")?; + } + if let Some(endpoint) = disc.device_authorization_endpoint.as_deref() { + validate_oauth_url(endpoint, "device_authorization_endpoint")?; + } let result = disc.clone(); @@ -330,37 +857,89 @@ impl ClientCredentialsSource { self.get_discovery().await.map(|disc| disc.token_endpoint) } - fn scopes_string(&self) -> String { - self.scopes.join(" ") + /// Resource/audience parameters forwarded to every authorization and + /// token request. + fn target_params(&self) -> impl Iterator { + self.resource + .as_deref() + .map(|value| ("resource", value)) + .into_iter() + .chain(self.audience.as_deref().map(|value| ("audience", value))) } - async fn post_token_request( - &self, - endpoint: &str, - params: &[(&str, &str)], - ) -> Result { - let resp = self - .http_client - .post(endpoint) - .form(params) - .send() - .await - .map_err(|e| Error::Runtime { - message: format!("Token request to {endpoint} failed: {e}"), - })?; + fn base_client(&self) -> BaseOauthClient { + let mut client = oauth2::Client::new(ClientId::new(self.client_id.clone())) + .set_auth_type(self.client_auth_method.auth_type()); + if let Some(secret) = self.client_secret.as_ref() { + client = client.set_client_secret(ClientSecret::new(secret.clone())); + } + client + } - if !resp.status().is_success() { - return Err(Error::Runtime { - message: format!( - "Token request failed with status {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ), + async fn token_client(&self) -> Result<(TokenEndpointClient, String)> { + let endpoint = self.get_token_endpoint().await?; + let token_url = TokenUrl::new(endpoint.clone()).map_err(|e| Error::InvalidInput { + message: format!("Invalid OAuth token_endpoint: {e}"), + })?; + Ok((self.base_client().set_token_uri(token_url), endpoint)) + } + + async fn refresh_token(&self, refresh_token: &str) -> Result { + let (client, _) = self.token_client().await?; + let refresh_token = RefreshToken::new(refresh_token.to_string()); + let mut request = client.exchange_refresh_token(&refresh_token); + for (name, value) in self.target_params() { + request = request.add_extra_param(name, value); + } + match request.request_async(&self.http_client).await { + Ok(response) => Ok(RefreshResult::Refreshed(response)), + Err(RequestTokenError::ServerResponse(response)) + if matches!(response.error().as_ref(), "invalid_grant" | "invalid_token") => + { + Ok(RefreshResult::Reauthenticate) + } + Err(error) => Err(map_token_error(error, "Refresh token request")), + } + } +} + +struct ClientCredentialsSource { + oidc: OidcClient, +} + +impl std::fmt::Debug for ClientCredentialsSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClientCredentialsSource") + .field("oidc", &self.oidc) + .finish() + } +} + +impl ClientCredentialsSource { + fn new( + issuer_url: String, + client_id: String, + client_secret: Option, + client_auth_method: ClientAuthMethod, + scopes: Vec, + resource: Option, + audience: Option, + ) -> Result { + if client_secret.is_none() { + return Err(Error::InvalidInput { + message: "client_secret is required for ClientCredentials flow".to_string(), }); } - - resp.json().await.map_err(|e| Error::Runtime { - message: format!("Failed to parse token response: {e}"), + Ok(Self { + oidc: OidcClient::new( + issuer_url, + client_id, + client_secret, + client_auth_method, + scopes, + resource, + audience, + )?, }) } } @@ -368,19 +947,551 @@ impl ClientCredentialsSource { #[async_trait] impl TokenSource for ClientCredentialsSource { async fn fetch_token(&self) -> Result { - let token_endpoint = self.get_token_endpoint().await?; - let scope = self.scopes_string(); - let params = [ - ("grant_type", "client_credentials"), - ("client_id", self.client_id.as_str()), - ("client_secret", self.client_secret.as_str()), - ("scope", scope.as_str()), - ]; + let (client, endpoint) = self.oidc.token_client().await?; + let mut request = client.exchange_client_credentials(); + for scope in &self.oidc.scopes { + request = request.add_scope(Scope::new(scope.clone())); + } + for (name, value) in self.oidc.target_params() { + request = request.add_extra_param(name, value); + } - self.post_token_request(&token_endpoint, ¶ms).await + request + .request_async(&self.oidc.http_client) + .await + .map_err(|e| map_token_error(e, &format!("Token request to {endpoint}"))) } } +#[derive(Debug)] +struct ResolvedRedirect { + uri: String, + bind_addr: SocketAddr, + callback_path: String, +} + +impl ResolvedRedirect { + fn new(options: &AuthorizationCodeOptions) -> Result { + let uri = options.redirect_uri.clone().unwrap_or_else(|| { + format!( + "http://127.0.0.1:{}/callback", + options.callback_port.unwrap_or(DEFAULT_CALLBACK_PORT) + ) + }); + let parsed = Url::parse(&uri).map_err(|e| Error::InvalidInput { + message: format!("Invalid OAuth redirect_uri: {e}"), + })?; + + if parsed.scheme() != "http" { + return Err(Error::InvalidInput { + message: "OAuth redirect_uri must use http with a loopback host".to_string(), + }); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(Error::InvalidInput { + message: "OAuth redirect_uri must not contain a query or fragment".to_string(), + }); + } + + let ip = match parsed.host() { + Some(url::Host::Domain(host)) if host.eq_ignore_ascii_case("localhost") => { + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) + } + Some(url::Host::Ipv4(ip)) if ip.is_loopback() => IpAddr::V4(ip), + Some(url::Host::Ipv6(ip)) if ip.is_loopback() => IpAddr::V6(ip), + Some(_) => { + return Err(Error::InvalidInput { + message: "OAuth redirect_uri must use a loopback host".to_string(), + }); + } + None => { + return Err(Error::InvalidInput { + message: "OAuth redirect_uri must include a loopback host".to_string(), + }); + } + }; + let port = parsed.port().ok_or(Error::InvalidInput { + message: "OAuth redirect_uri must include a port".to_string(), + })?; + if port == 0 { + return Err(Error::InvalidInput { + message: "OAuth redirect_uri port must be greater than zero".to_string(), + }); + } + if let Some(callback_port) = options.callback_port + && callback_port != port + { + return Err(Error::InvalidInput { + message: format!( + "OAuth callback_port {callback_port} does not match redirect_uri port {port}" + ), + }); + } + + Ok(Self { + uri, + bind_addr: SocketAddr::new(ip, port), + callback_path: parsed.path().to_string(), + }) + } +} + +#[derive(Debug)] +struct AuthorizationRequest { + url: Url, + state: String, + code_verifier: Option, +} + +#[derive(Debug, PartialEq)] +enum AuthorizationCallback { + Code(String), + ProviderError(String), +} + +struct AuthorizationCodeSource { + oidc: OidcClient, + options: AuthorizationCodeOptions, + redirect: ResolvedRedirect, +} + +impl std::fmt::Debug for AuthorizationCodeSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthorizationCodeSource") + .field("oidc", &self.oidc) + .field("options", &self.options) + .field("redirect", &self.redirect) + .finish() + } +} + +impl AuthorizationCodeSource { + #[allow(clippy::too_many_arguments)] + fn new( + issuer_url: String, + client_id: String, + client_secret: Option, + client_auth_method: ClientAuthMethod, + scopes: Vec, + resource: Option, + audience: Option, + options: AuthorizationCodeOptions, + ) -> Result { + let redirect = ResolvedRedirect::new(&options)?; + Ok(Self { + oidc: OidcClient::new( + issuer_url, + client_id, + client_secret, + client_auth_method, + scopes, + resource, + audience, + )?, + options, + redirect, + }) + } + + async fn build_authorization_request(&self) -> Result { + let endpoint = self + .oidc + .get_discovery() + .await? + .authorization_endpoint + .ok_or(Error::Runtime { + message: "OIDC discovery did not provide authorization_endpoint".to_string(), + })?; + let auth_url = AuthUrl::new(endpoint).map_err(|e| Error::InvalidInput { + message: format!("Invalid OAuth authorization_endpoint: {e}"), + })?; + let redirect_url = + RedirectUrl::new(self.redirect.uri.clone()).map_err(|e| Error::InvalidInput { + message: format!("Invalid OAuth redirect_uri: {e}"), + })?; + + let pkce = self + .options + .use_pkce + .then(PkceCodeChallenge::new_random_sha256); + + let client = self + .oidc + .base_client() + .set_auth_uri(auth_url) + .set_redirect_uri(redirect_url); + let mut request = client.authorize_url(CsrfToken::new_random); + for scope in &self.oidc.scopes { + request = request.add_scope(Scope::new(scope.clone())); + } + for (name, value) in self.oidc.target_params() { + request = request.add_extra_param(name, value); + } + if let Some((challenge, _)) = pkce.as_ref() { + request = request.set_pkce_challenge(challenge.clone()); + } + let (url, state) = request.url(); + + Ok(AuthorizationRequest { + url, + state: state.secret().clone(), + code_verifier: pkce.map(|(_, verifier)| verifier), + }) + } + + async fn wait_for_callback( + &self, + listener: &TcpListener, + expected_state: &str, + ) -> Result { + let deadline = + TokioInstant::now() + Duration::from_secs(AUTHORIZATION_CALLBACK_TIMEOUT_SECS); + loop { + let (mut stream, _) = tokio::time::timeout_at(deadline, listener.accept()) + .await + .map_err(|_| Error::Runtime { + message: "Timed out waiting for the OAuth authorization callback".to_string(), + })? + .map_err(|e| Error::Runtime { + message: format!("Failed to accept OAuth callback connection: {e}"), + })?; + match read_authorization_callback( + &mut stream, + &self.redirect.callback_path, + expected_state, + deadline, + ) + .await + { + Ok(AuthorizationCallback::Code(code)) => { + write_callback_response(&mut stream, true).await; + return Ok(code); + } + Ok(AuthorizationCallback::ProviderError(message)) => { + write_callback_response(&mut stream, false).await; + return Err(Error::Runtime { message }); + } + Err(error) => { + if TokioInstant::now() >= deadline { + return Err(Error::Runtime { + message: "Timed out waiting for the OAuth authorization callback" + .to_string(), + }); + } + debug!("Ignoring unrelated OAuth callback connection: {error}"); + write_callback_response(&mut stream, false).await; + } + } + } + } + + async fn exchange_code( + &self, + code: &str, + code_verifier: Option, + ) -> Result { + let (client, endpoint) = self.oidc.token_client().await?; + let redirect_url = + RedirectUrl::new(self.redirect.uri.clone()).map_err(|e| Error::InvalidInput { + message: format!("Invalid OAuth redirect_uri: {e}"), + })?; + let mut request = client + .exchange_code(oauth2::AuthorizationCode::new(code.to_string())) + .set_redirect_uri(Cow::Owned(redirect_url)); + for (name, value) in self.oidc.target_params() { + request = request.add_extra_param(name, value); + } + if let Some(verifier) = code_verifier { + request = request.set_pkce_verifier(verifier); + } + + request + .request_async(&self.oidc.http_client) + .await + .map_err(|e| map_token_error(e, &format!("Token request to {endpoint}"))) + } +} + +#[async_trait] +impl TokenSource for AuthorizationCodeSource { + async fn fetch_token(&self) -> Result { + let listener = TcpListener::bind(self.redirect.bind_addr) + .await + .map_err(|e| Error::Runtime { + message: format!( + "Failed to bind OAuth callback server at {}: {e}", + self.redirect.bind_addr + ), + })?; + let request = self.build_authorization_request().await?; + show_oauth_prompt(&authorization_prompt(&request.url)); + launch_browser(request.url.clone()); + let code = self.wait_for_callback(&listener, &request.state).await?; + self.exchange_code(&code, request.code_verifier).await + } + + async fn refresh_token(&self, refresh_token: &str) -> Result { + self.oidc.refresh_token(refresh_token).await + } +} + +/// A minimal OAuth error body, used to sniff retryable `temporarily_unavailable` +/// responses in [`OAuthHttpClient`]. Unknown fields are ignored. +#[derive(Debug, Deserialize)] +struct OAuthErrorResponse { + error: String, +} + +struct DeviceCodeSource { + oidc: OidcClient, +} + +impl std::fmt::Debug for DeviceCodeSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DeviceCodeSource") + .field("oidc", &self.oidc) + .finish() + } +} + +impl DeviceCodeSource { + fn new( + issuer_url: String, + client_id: String, + client_secret: Option, + client_auth_method: ClientAuthMethod, + scopes: Vec, + resource: Option, + audience: Option, + ) -> Result { + Ok(Self { + oidc: OidcClient::new( + issuer_url, + client_id, + client_secret, + client_auth_method, + scopes, + resource, + audience, + )?, + }) + } + + async fn request_device_authorization(&self) -> Result { + let endpoint = self + .oidc + .get_discovery() + .await? + .device_authorization_endpoint + .ok_or(Error::Runtime { + message: "OIDC discovery did not provide device_authorization_endpoint".to_string(), + })?; + let device_url = + DeviceAuthorizationUrl::new(endpoint.clone()).map_err(|e| Error::InvalidInput { + message: format!("Invalid OAuth device_authorization_endpoint: {e}"), + })?; + + let client = self + .oidc + .base_client() + .set_device_authorization_url(device_url); + let mut request = client.exchange_device_code(); + for scope in &self.oidc.scopes { + request = request.add_scope(Scope::new(scope.clone())); + } + for (name, value) in self.oidc.target_params() { + request = request.add_extra_param(name, value); + } + let device: StandardDeviceAuthorizationResponse = request + .request_async(&self.oidc.http_client) + .await + .map_err(|e| { + map_token_error(e, &format!("Device authorization request to {endpoint}")) + })?; + + validate_oauth_url(device.verification_uri().as_str(), "verification_uri")?; + if let Some(uri) = device.verification_uri_complete() { + validate_oauth_url(uri.secret(), "verification_uri_complete")?; + } + Ok(device) + } + + async fn poll_for_token( + &self, + device: &StandardDeviceAuthorizationResponse, + ) -> Result { + let (client, _) = self.oidc.token_client().await?; + let mut request = client + .exchange_device_access_token(device) + .set_max_backoff_interval(Duration::from_secs(10)); + for (name, value) in self.oidc.target_params() { + request = request.add_extra_param(name, value); + } + request + .request_async( + &self.oidc.http_client, + // RFC 8628: poll slowly; never spin faster than once a second + // even if a misbehaving server reports a zero interval. + |interval: Duration| tokio::time::sleep(interval.max(Duration::from_secs(1))), + None, + ) + .await + .map_err(map_device_token_error) + } +} + +#[async_trait] +impl TokenSource for DeviceCodeSource { + async fn fetch_token(&self) -> Result { + let device = self.request_device_authorization().await?; + show_oauth_prompt(&device_prompt( + device.verification_uri().as_str(), + device.user_code().secret(), + )); + let (browser_url, name) = device + .verification_uri_complete() + .map(|uri| (uri.secret().as_str(), "verification_uri_complete")) + .unwrap_or((device.verification_uri().as_str(), "verification_uri")); + launch_browser(validate_oauth_url(browser_url, name)?); + self.poll_for_token(&device).await + } + + async fn refresh_token(&self, refresh_token: &str) -> Result { + self.oidc.refresh_token(refresh_token).await + } +} + +fn launch_browser(url: Url) { + drop(tokio::task::spawn_blocking(move || { + if let Some(browser) = std::env::var_os("LANCEDB_OAUTH_BROWSER") { + match Command::new(browser).arg(url.as_str()).status() { + Ok(status) if !status.success() => { + warn!("OAuth browser helper exited with status {status}"); + } + Err(error) => warn!("Could not run the OAuth browser helper: {error}"), + Ok(_) => {} + } + } else if let Err(error) = webbrowser::open(url.as_str()) { + warn!("Could not open an OAuth browser automatically: {error}"); + } + })); +} + +async fn read_authorization_callback( + stream: &mut TcpStream, + expected_path: &str, + expected_state: &str, + overall_deadline: TokioInstant, +) -> Result { + const MAX_CALLBACK_REQUEST_BYTES: usize = 16 * 1024; + let deadline = std::cmp::min( + overall_deadline, + TokioInstant::now() + Duration::from_secs(10), + ); + let mut request = Vec::with_capacity(1024); + loop { + let mut buffer = [0; 1024]; + let count = tokio::time::timeout_at(deadline, stream.read(&mut buffer)) + .await + .map_err(|_| Error::Runtime { + message: "Timed out reading the OAuth authorization callback".to_string(), + })? + .map_err(|e| Error::Runtime { + message: format!("Failed to read OAuth authorization callback: {e}"), + })?; + if count == 0 { + return Err(Error::Runtime { + message: "OAuth authorization callback closed before sending a request".to_string(), + }); + } + request.extend_from_slice(&buffer[..count]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + if request.len() >= MAX_CALLBACK_REQUEST_BYTES { + return Err(Error::Runtime { + message: "OAuth authorization callback request was too large".to_string(), + }); + } + } + let request = std::str::from_utf8(&request).map_err(|e| Error::Runtime { + message: format!("OAuth authorization callback was not valid UTF-8: {e}"), + })?; + parse_authorization_callback(request, expected_path, expected_state) +} + +fn parse_authorization_callback( + request: &str, + expected_path: &str, + expected_state: &str, +) -> Result { + let request_target = request + .lines() + .next() + .and_then(|line| { + let mut parts = line.split_whitespace(); + (parts.next() == Some("GET")) + .then(|| parts.next()) + .flatten() + }) + .ok_or(Error::Runtime { + message: "OAuth authorization callback was not a valid HTTP GET request".to_string(), + })?; + let callback = + Url::parse(&format!("http://loopback{request_target}")).map_err(|e| Error::Runtime { + message: format!("OAuth authorization callback URL was invalid: {e}"), + })?; + if callback.path() != expected_path { + return Err(Error::Runtime { + message: format!( + "OAuth authorization callback used unexpected path {}", + callback.path() + ), + }); + } + let params: HashMap<_, _> = callback.query_pairs().into_owned().collect(); + if params.get("state").map(String::as_str) != Some(expected_state) { + return Err(Error::Runtime { + message: "OAuth authorization callback state did not match".to_string(), + }); + } + if let Some(error) = params.get("error") { + let description = params + .get("error_description") + .map(String::as_str) + .unwrap_or(error); + return Ok(AuthorizationCallback::ProviderError(format!( + "OAuth authorization failed: {description}" + ))); + } + params + .get("code") + .cloned() + .map(AuthorizationCallback::Code) + .ok_or(Error::Runtime { + message: "OAuth authorization callback did not contain a code".to_string(), + }) +} + +async fn write_callback_response(stream: &mut TcpStream, success: bool) { + let (status, body) = if success { + ( + "200 OK", + "

Authentication successful

You can close this window.

", + ) + } else { + ( + "400 Bad Request", + "

Authentication failed

Return to the application for details.

", + ) + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; +} + struct AzureImdsSource { client_id: Option, resource: String, @@ -463,20 +1574,84 @@ impl TokenSource for AzureImdsSource { } } +/// Build the token source for a configuration. +/// +/// Shared by [`OAuthHeaderProvider`] and +/// [`OAuthSession`](crate::remote::OAuthSession). +pub(crate) fn build_token_source(config: &OAuthConfig) -> Result> { + if matches!(config.flow, OAuthFlow::AzureManagedIdentity { .. }) + && (config.resource.is_some() || config.audience.is_some()) + { + return Err(Error::InvalidInput { + message: "resource and audience are not supported for AzureManagedIdentity; configure its resource through scopes".to_string(), + }); + } + if config.scopes.is_empty() { + return Err(Error::InvalidInput { + message: "At least one OAuth scope is required".to_string(), + }); + } + let client_auth_method = + resolve_client_auth_method(config.client_auth_method, config.client_secret.as_deref())?; + Ok(match &config.flow { + OAuthFlow::ClientCredentials => Box::new(ClientCredentialsSource::new( + config.issuer_url.clone(), + config.client_id.clone(), + config.client_secret.clone(), + client_auth_method, + config.scopes.clone(), + config.resource.clone(), + config.audience.clone(), + )?), + OAuthFlow::AuthorizationCode(options) => Box::new(AuthorizationCodeSource::new( + config.issuer_url.clone(), + config.client_id.clone(), + config.client_secret.clone(), + client_auth_method, + config.scopes.clone(), + config.resource.clone(), + config.audience.clone(), + options.clone(), + )?), + OAuthFlow::DeviceCode => Box::new(DeviceCodeSource::new( + config.issuer_url.clone(), + config.client_id.clone(), + config.client_secret.clone(), + client_auth_method, + config.scopes.clone(), + config.resource.clone(), + config.audience.clone(), + )?), + OAuthFlow::AzureManagedIdentity { client_id } => Box::new(AzureImdsSource::new( + config.scopes.clone(), + client_id.clone(), + )?), + }) +} + /// OAuth header provider that manages the full token lifecycle. /// /// Implements [`HeaderProvider`] to inject `Authorization: Bearer ` -/// headers into every LanceDB request, with automatic token refresh. +/// headers into every LanceDB request, with automatic token refresh. It also +/// identifies the bearer credential as OIDC so LanceDB's SQL service selects +/// OIDC validation instead of API-key validation. +/// +/// When the configuration enables +/// [`token_cache`](OAuthConfig::token_cache), tokens are additionally shared +/// through a hardened on-disk cache so separate processes reuse one session; +/// see [`crate::remote::token_cache`]. pub struct OAuthHeaderProvider { token_source: Box, token_state: Arc>, refresh_buffer: Duration, + token_cache: Option>, } impl std::fmt::Debug for OAuthHeaderProvider { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("OAuthHeaderProvider") .field("token_source", &self.token_source) + .field("token_cache", &self.token_cache) .finish() } } @@ -484,39 +1659,19 @@ impl std::fmt::Debug for OAuthHeaderProvider { impl OAuthHeaderProvider { /// Create a new OAuth header provider from configuration. pub fn new(config: OAuthConfig) -> Result { - let OAuthConfig { - issuer_url, - client_id, - client_secret, - scopes, - flow, - refresh_buffer_secs, - } = config; - - if scopes.is_empty() { - return Err(Error::InvalidInput { - message: "At least one OAuth scope is required".to_string(), - }); - } - - let refresh_buffer = - Duration::from_secs(refresh_buffer_secs.unwrap_or(DEFAULT_REFRESH_BUFFER_SECS)); - let token_source: Box = match flow { - OAuthFlow::ClientCredentials => Box::new(ClientCredentialsSource::new( - issuer_url, - client_id, - client_secret, - scopes, - )?), - OAuthFlow::AzureManagedIdentity { client_id } => { - Box::new(AzureImdsSource::new(scopes, client_id)?) - } - }; + let refresh_buffer = Duration::from_secs( + config + .refresh_buffer_secs + .unwrap_or(DEFAULT_REFRESH_BUFFER_SECS), + ); + let token_source = build_token_source(&config)?; + let token_cache = crate::remote::token_cache::token_cache_for_config(&config)?; Ok(Self { token_source, token_state: Arc::new(RwLock::new(TokenState::new())), refresh_buffer, + token_cache, }) } @@ -542,11 +1697,37 @@ impl OAuthHeaderProvider { return Ok(token.clone()); } - debug!("Acquiring new OAuth token via {:?}", self.token_source); - let resp = self.token_source.fetch_token().await?; + if let Some(cache) = &self.token_cache { + // Cross-process critical section: serialize with other processes, + // reread the durable record, refresh or acquire exactly once, and + // persist the rotated refresh token. + let resp = cache.refresh_or_acquire(self.token_source.as_ref()).await?; + state.update(&resp); + return Ok(resp.access_token.secret().clone()); + } + + let refresh_token = state.refresh_token.clone(); + let resp = if let Some(refresh_token) = refresh_token.as_deref() { + debug!("Refreshing OAuth access token via {:?}", self.token_source); + match self.token_source.refresh_token(refresh_token).await? { + RefreshResult::Refreshed(response) => response, + RefreshResult::Unsupported => self.token_source.fetch_token().await?, + RefreshResult::Reauthenticate => { + warn!( + "OAuth refresh token was rejected; acquiring a new token via {:?}", + self.token_source + ); + state.refresh_token = None; + self.token_source.fetch_token().await? + } + } + } else { + debug!("Acquiring new OAuth token via {:?}", self.token_source); + self.token_source.fetch_token().await? + }; state.update(&resp); - Ok(resp.access_token) + Ok(resp.access_token.secret().clone()) } } @@ -554,10 +1735,10 @@ impl OAuthHeaderProvider { impl HeaderProvider for OAuthHeaderProvider { async fn get_headers(&self) -> Result> { let token = self.get_valid_token().await?; - Ok(HashMap::from([( - "authorization".to_string(), - format!("Bearer {token}"), - )])) + Ok(HashMap::from([ + ("authorization".to_string(), format!("Bearer {token}")), + ("x-lancedb-credential-type".to_string(), "oidc".to_string()), + ])) } } @@ -566,10 +1747,194 @@ mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; + use base64::Engine; + use oauth2::TokenResponse as _; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::task::JoinHandle; + fn token_response( + access_token: &str, + refresh_token: Option<&str>, + expires_in: Option, + ) -> TokenResponse { + TokenResponse { + access_token: AccessToken::new(access_token.to_string()), + refresh_token: refresh_token.map(|token| RefreshToken::new(token.to_string())), + expires_in, + token_type: None, + } + } + + fn basic_authorization(client_id: &str, client_secret: &str) -> String { + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{client_id}:{client_secret}")) + ) + } + + struct CapturedRequest { + line: String, + headers: String, + body: String, + } + + impl CapturedRequest { + fn header(&self, name: &str) -> Option { + self.headers.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + key.trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + } + } + + #[tokio::test] + async fn test_target_parameters_across_oauth_flows() { + for (resource, audience) in [ + (None, None), + (Some("https://api.example.com/a?x=1&y=two"), None), + (None, Some("audience + & / ü")), + (Some("urn:example:resource"), Some("audience + & / ü")), + ] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer = format!("http://{addr}"); + let server = tokio::spawn(async move { + let mut grants = Vec::new(); + // Three discovery requests and six form submissions. + for _ in 0..9 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + let response = if request.line.starts_with("GET ") { + serde_json::json!({ + "token_endpoint": format!("http://{addr}/token"), + "authorization_endpoint": format!("http://{addr}/authorize"), + "device_authorization_endpoint": format!("http://{addr}/device"), + }) + } else { + let params: Vec<_> = + url::form_urlencoded::parse(request.body.as_bytes()).collect(); + for (key, expected) in [("resource", resource), ("audience", audience)] { + let values: Vec<_> = params + .iter() + .filter(|(name, _)| name == key) + .map(|(_, value)| value.as_ref()) + .collect(); + assert_eq!(values, expected.into_iter().collect::>()); + } + if request.line.starts_with("POST /device ") { + serde_json::json!({ + "device_code": "device-code", "user_code": "ABCD", + "verification_uri": format!("http://{addr}/verify"), + "expires_in": 60, "interval": 1, + }) + } else { + grants.push( + params + .iter() + .find(|(key, _)| key == "grant_type") + .unwrap() + .1 + .to_string(), + ); + serde_json::json!({"access_token": "access", "refresh_token": "refresh", "expires_in": 3600}) + } + }; + write_json_response(&mut stream, "200 OK", &response.to_string()).await; + } + assert_eq!( + grants, + [ + "client_credentials", + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "refresh_token" + ] + ); + }); + let credentials = ClientCredentialsSource::new( + issuer.clone(), + "client".into(), + Some("secret".into()), + ClientAuthMethod::ClientSecretBasic, + vec!["scope".into()], + resource.map(str::to_owned), + audience.map(str::to_owned), + ) + .unwrap(); + credentials.fetch_token().await.unwrap(); + let browser = AuthorizationCodeSource::new( + issuer.clone(), + "client".into(), + None, + ClientAuthMethod::None, + vec!["scope".into()], + resource.map(str::to_owned), + audience.map(str::to_owned), + AuthorizationCodeOptions::new(), + ) + .unwrap(); + let request = browser.build_authorization_request().await.unwrap(); + for (key, expected) in [("resource", resource), ("audience", audience)] { + let values: Vec<_> = request + .url + .query_pairs() + .filter(|(name, _)| name == key) + .map(|(_, value)| value.into_owned()) + .collect(); + assert_eq!( + values, + expected.into_iter().map(str::to_owned).collect::>() + ); + } + browser + .exchange_code("code", Some(PkceCodeVerifier::new("verifier".to_string()))) + .await + .unwrap(); + browser.refresh_token("refresh").await.unwrap(); + let device = DeviceCodeSource::new( + issuer, + "client".into(), + None, + ClientAuthMethod::None, + vec!["scope".into()], + resource.map(str::to_owned), + audience.map(str::to_owned), + ) + .unwrap(); + let authorization = device.request_device_authorization().await.unwrap(); + device.poll_for_token(&authorization).await.unwrap(); + device.refresh_token("refresh").await.unwrap(); + server.await.unwrap(); + } + } + + #[test] + fn test_managed_identity_rejects_target_parameters() { + for (resource, audience) in [(Some("urn:resource"), None), (None, Some("audience"))] { + let config = OAuthConfig { + issuer_url: "https://issuer.example.com".into(), + client_id: "client".into(), + client_secret: None, + scopes: vec!["api://app/.default".into()], + resource: resource.map(str::to_owned), + audience: audience.map(str::to_owned), + flow: OAuthFlow::AzureManagedIdentity { client_id: None }, + client_auth_method: None, + refresh_buffer_secs: None, + token_cache: None, + }; + let error = OAuthHeaderProvider::new(config).unwrap_err().to_string(); + assert!( + error.contains("resource and audience are not supported for AzureManagedIdentity") + ); + } + } + #[test] fn test_token_state_expiry() { let mut state = TokenState::new(); @@ -587,18 +1952,21 @@ mod tests { #[test] fn test_token_state_uses_default_expiry() { let mut state = TokenState::new(); - let response = TokenResponse { - access_token: "tok".to_string(), - expires_in: None, - token_type: None, - }; - - state.update(&response); + state.update(&token_response("tok", None, None)); assert!(!state.is_expired(Duration::from_secs(DEFAULT_TOKEN_TTL_SECS - 1))); assert!(state.is_expired(Duration::from_secs(DEFAULT_TOKEN_TTL_SECS + 1))); } + #[test] + fn test_token_state_retains_refresh_token_when_not_rotated() { + let mut state = TokenState::new(); + state.update(&token_response("token-1", Some("refresh-1"), Some(60))); + state.update(&token_response("token-2", None, Some(60))); + + assert_eq!(state.refresh_token.as_deref(), Some("refresh-1")); + } + #[test] fn test_token_response_accepts_float_expires_in() { let response: TokenResponse = @@ -619,60 +1987,983 @@ mod tests { #[test] fn test_token_response_debug_redacts_access_token() { let response = TokenResponse { - access_token: "secret-token".to_string(), + access_token: AccessToken::new("secret-token".to_string()), + refresh_token: Some(RefreshToken::new("secret-refresh-token".to_string())), expires_in: Some(3600), - token_type: Some("Bearer".to_string()), + token_type: Some(BasicTokenType::Bearer), }; let debug = format!("{response:?}"); assert!(!debug.contains("secret-token")); + assert!(!debug.contains("secret-refresh-token")); assert!(debug.contains("access_token: \"\"")); } #[test] - fn test_scopes_string() { - let source = ClientCredentialsSource::new( - "https://login.microsoftonline.com/tenant/v2.0".to_string(), - "app-id".to_string(), - Some("secret".to_string()), - vec!["scope1".to_string(), "scope2".to_string()], + fn test_client_auth_method_defaults() { + assert_eq!( + resolve_client_auth_method(None, Some("secret")).unwrap(), + ClientAuthMethod::ClientSecretBasic + ); + assert_eq!( + resolve_client_auth_method(None, None).unwrap(), + ClientAuthMethod::None + ); + } + + #[test] + fn test_client_auth_method_explicit_values() { + for method in [ + ClientAuthMethod::None, + ClientAuthMethod::ClientSecretBasic, + ClientAuthMethod::ClientSecretPost, + ] { + let secret = (method != ClientAuthMethod::None).then_some("secret"); + assert_eq!( + resolve_client_auth_method(Some(method), secret).unwrap(), + method + ); + } + } + + #[test] + fn test_client_auth_method_rejects_inconsistent_configuration() { + let err = + resolve_client_auth_method(Some(ClientAuthMethod::None), Some("secret")).unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message == "client_auth_method None cannot be combined with client_secret" + )); + + for method in [ + ClientAuthMethod::ClientSecretBasic, + ClientAuthMethod::ClientSecretPost, + ] { + let err = resolve_client_auth_method(Some(method), None).unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message == format!("client_auth_method {method:?} requires client_secret to be set") + )); + } + } + + #[test] + fn test_oauth_transport_requires_https_except_for_loopback() { + assert!(validate_oauth_url("https://idp.example.com/token", "endpoint").is_ok()); + assert!(validate_oauth_url("http://localhost:8080/token", "endpoint").is_ok()); + assert!(validate_oauth_url("http://127.0.0.1:8080/token", "endpoint").is_ok()); + assert!(validate_oauth_url("http://[::1]:8080/token", "endpoint").is_ok()); + + let err = validate_oauth_url("http://idp.example.com/token", "endpoint").unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message == "OAuth endpoint must use https, except for http on a loopback host" + )); + } + + #[test] + fn test_interactive_prompts_use_default_visible_output() { + let authorization_url = Url::parse("https://idp.example.com/authorize?state=abc").unwrap(); + let authorization = authorization_prompt(&authorization_url); + let device = device_prompt("https://idp.example.com/device", "ABCD-EFGH"); + let mut output = Vec::new(); + + write_oauth_prompt(&mut output, &authorization); + write_oauth_prompt(&mut output, &device); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains(authorization_url.as_str())); + assert!(output.contains("https://idp.example.com/device")); + assert!(output.contains("ABCD-EFGH")); + } + + #[test] + fn test_authorization_code_options_default_to_pkce() { + let options = AuthorizationCodeOptions::new(); + + assert!(options.use_pkce); + assert!(options.redirect_uri.is_none()); + assert!(options.callback_port.is_none()); + } + + #[test] + fn test_authorization_redirect_defaults_to_ipv4_loopback() { + let redirect = ResolvedRedirect::new(&AuthorizationCodeOptions::new()).unwrap(); + + assert_eq!( + redirect.uri, + format!("http://127.0.0.1:{DEFAULT_CALLBACK_PORT}/callback") + ); + assert!(redirect.bind_addr.ip().is_loopback()); + assert_eq!(redirect.bind_addr.port(), DEFAULT_CALLBACK_PORT); + assert_eq!(redirect.callback_path, "/callback"); + } + + #[test] + fn test_authorization_redirect_rejects_non_loopback_host() { + let options = AuthorizationCodeOptions::new() + .redirect_uri("https://client.example.com/oauth/callback"); + + let err = ResolvedRedirect::new(&options).unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message == "OAuth redirect_uri must use http with a loopback host" + )); + } + + #[test] + fn test_authorization_redirect_rejects_mismatched_port() { + let options = AuthorizationCodeOptions::new() + .redirect_uri("http://127.0.0.1:8401/callback") + .callback_port(8400); + + let err = ResolvedRedirect::new(&options).unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message.contains("does not match redirect_uri port") + )); + } + + #[test] + fn test_authorization_redirect_requires_explicit_port() { + let options = AuthorizationCodeOptions::new().redirect_uri("http://127.0.0.1/callback"); + + let err = ResolvedRedirect::new(&options).unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message == "OAuth redirect_uri must include a port" + )); + } + + #[test] + fn test_authorization_redirect_accepts_ipv6_loopback() { + let options = AuthorizationCodeOptions::new().redirect_uri("http://[::1]:8400/callback"); + + let redirect = ResolvedRedirect::new(&options).unwrap(); + assert_eq!( + redirect.bind_addr, + "[::1]:8400".parse::().unwrap() + ); + } + + #[test] + fn test_authorization_callback_parsing() { + let callback = parse_authorization_callback( + "GET /callback?code=abc%20123&state=expected HTTP/1.1\r\nHost: localhost\r\n", + "/callback", + "expected", ) .unwrap(); - assert_eq!(source.scopes_string(), "scope1 scope2"); + assert_eq!(callback, AuthorizationCallback::Code("abc 123".to_string())); + } + + #[test] + fn test_authorization_callback_rejects_state_mismatch() { + let err = parse_authorization_callback( + "GET /callback?code=abc&state=wrong HTTP/1.1\r\nHost: localhost\r\n", + "/callback", + "expected", + ) + .unwrap_err(); + + assert!(matches!( + err, + Error::Runtime { message } + if message == "OAuth authorization callback state did not match" + )); + } + + #[test] + fn test_authorization_callback_reports_provider_error() { + let callback = parse_authorization_callback( + "GET /callback?error=access_denied&error_description=user+cancelled&state=expected HTTP/1.1\r\nHost: localhost\r\n", + "/callback", + "expected", + ) + .unwrap(); + + assert_eq!( + callback, + AuthorizationCallback::ProviderError( + "OAuth authorization failed: user cancelled".to_string() + ) + ); + } + + #[tokio::test] + async fn test_authorization_callback_ignores_unrelated_connection_and_partial_read() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let source = AuthorizationCodeSource::new( + "http://127.0.0.1:1".to_string(), + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new() + .redirect_uri(format!("http://127.0.0.1:{port}/callback")), + ) + .unwrap(); + + let browser = tokio::spawn(async move { + let mut unrelated = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + unrelated + .write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut response = Vec::new(); + unrelated.read_to_end(&mut response).await.unwrap(); + assert!( + String::from_utf8(response) + .unwrap() + .starts_with("HTTP/1.1 400") + ); + + let mut callback = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + callback + .write_all(b"GET /callback?code=auth") + .await + .unwrap(); + tokio::task::yield_now().await; + callback + .write_all(b"-code&state=expected HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + }); + + assert_eq!( + source + .wait_for_callback(&listener, "expected") + .await + .unwrap(), + "auth-code" + ); + browser.await.unwrap(); + } + + #[tokio::test] + async fn test_authorization_callback_read_respects_overall_deadline() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let client = tokio::spawn(async move { + let _stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + }); + let (mut stream, _) = listener.accept().await.unwrap(); + + let err = read_authorization_callback( + &mut stream, + "/callback", + "expected", + TokioInstant::now() + Duration::from_millis(20), + ) + .await + .unwrap_err(); + + assert!(matches!( + err, + Error::Runtime { message } + if message == "Timed out reading the OAuth authorization callback" + )); + client.abort(); + } + + #[tokio::test] + async fn test_authorization_request_uses_pkce_by_default() { + let (issuer_url, server) = spawn_discovery_server(1).await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string(), "profile".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + let request = source.build_authorization_request().await.unwrap(); + let params: HashMap<_, _> = request.url.query_pairs().into_owned().collect(); + assert_eq!( + params.get("response_type").map(String::as_str), + Some("code") + ); + assert_eq!( + params.get("client_id").map(String::as_str), + Some("client-id") + ); + assert_eq!( + params.get("redirect_uri").map(String::as_str), + Some("http://127.0.0.1:8400/callback") + ); + assert_eq!( + params.get("scope").map(String::as_str), + Some("openid profile") + ); + assert!(params.get("state").is_some_and(|state| state.len() >= 16)); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert!(params.contains_key("code_challenge")); + assert!(request.code_verifier.is_some()); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_authorization_request_rejects_plaintext_provider_endpoint() { + let (issuer_url, server) = spawn_insecure_authorization_discovery_server().await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + let err = source.build_authorization_request().await.unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message.contains("authorization_endpoint must use https") + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_authorization_request_can_disable_pkce() { + let (issuer_url, server) = spawn_discovery_server(1).await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + Some("secret".to_string()), + ClientAuthMethod::ClientSecretBasic, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new().use_pkce(false), + ) + .unwrap(); + + let request = source.build_authorization_request().await.unwrap(); + let params: HashMap<_, _> = request.url.query_pairs().into_owned().collect(); + assert!(!params.contains_key("code_challenge")); + assert!(!params.contains_key("code_challenge_method")); + assert!(request.code_verifier.is_none()); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_authorization_code_exchange_public_client_sends_client_id_only() { + let (issuer_url, request, server) = spawn_captured_token_server().await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + let response = source + .exchange_code( + "auth-code", + Some(PkceCodeVerifier::new("verifier".to_string())), + ) + .await + .unwrap(); + assert_eq!(response.access_token.secret(), "token"); + + let request = request.lock().unwrap().take().unwrap(); + assert_eq!(request.header("authorization"), None); + assert!(request.body.contains("grant_type=authorization_code")); + assert!(request.body.contains("code=auth-code")); + assert!(request.body.contains("code_verifier=verifier")); + assert!(request.body.contains("client_id=client-id")); + assert!(!request.body.contains("client_secret")); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_authorization_code_exchange_uses_basic_auth_by_default() { + let (issuer_url, request, server) = spawn_captured_token_server().await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + Some("secret".to_string()), + ClientAuthMethod::ClientSecretBasic, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + source + .exchange_code( + "auth-code", + Some(PkceCodeVerifier::new("verifier".to_string())), + ) + .await + .unwrap(); + + let request = request.lock().unwrap().take().unwrap(); + assert_eq!( + request.header("authorization").as_deref(), + Some(basic_authorization("client-id", "secret").as_str()) + ); + assert!(request.body.contains("grant_type=authorization_code")); + assert!(request.body.contains("code=auth-code")); + assert!(request.body.contains("code_verifier=verifier")); + assert!(!request.body.contains("client_secret")); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_authorization_code_exchange_supports_client_secret_post() { + let (issuer_url, request, server) = spawn_captured_token_server().await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + Some("secret".to_string()), + ClientAuthMethod::ClientSecretPost, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + source + .exchange_code( + "auth-code", + Some(PkceCodeVerifier::new("verifier".to_string())), + ) + .await + .unwrap(); + + let request = request.lock().unwrap().take().unwrap(); + assert_eq!(request.header("authorization"), None); + assert!(request.body.contains("client_id=client-id")); + assert!(request.body.contains("client_secret=secret")); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_refresh_uses_basic_auth_by_default() { + let (issuer_url, request, server) = spawn_captured_token_server().await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + Some("secret".to_string()), + ClientAuthMethod::ClientSecretBasic, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + assert!(matches!( + source.oidc.refresh_token("refresh-token").await.unwrap(), + RefreshResult::Refreshed(_) + )); + + let request = request.lock().unwrap().take().unwrap(); + assert_eq!( + request.header("authorization").as_deref(), + Some(basic_authorization("client-id", "secret").as_str()) + ); + assert!(request.body.contains("grant_type=refresh_token")); + assert!(request.body.contains("refresh_token=refresh-token")); + assert!(!request.body.contains("client_secret")); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_refresh_supports_client_secret_post_and_rotation() { + let (issuer_url, request, server) = spawn_captured_token_server().await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + Some("secret".to_string()), + ClientAuthMethod::ClientSecretPost, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + let response = match source.oidc.refresh_token("old-refresh").await.unwrap() { + RefreshResult::Refreshed(response) => response, + other => panic!("expected refresh, got {other:?}"), + }; + assert_eq!(response.refresh_token().unwrap().secret(), "refresh"); + + let request = request.lock().unwrap().take().unwrap(); + assert_eq!(request.header("authorization"), None); + assert!(request.body.contains("grant_type=refresh_token")); + assert!(request.body.contains("refresh_token=old-refresh")); + assert!(request.body.contains("client_id=client-id")); + assert!(request.body.contains("client_secret=secret")); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_refresh_invalid_grant_requires_reauthentication() { + let (issuer_url, server) = + spawn_refresh_error_server("400 Bad Request", r#"{"error":"invalid_grant"}"#).await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + assert!(matches!( + source.oidc.refresh_token("revoked").await.unwrap(), + RefreshResult::Reauthenticate + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_refresh_transient_failure_remains_retryable() { + let (issuer_url, server) = spawn_refresh_error_server( + "503 Service Unavailable", + r#"{"error":"temporarily_unavailable"}"#, + ) + .await; + let source = AuthorizationCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + AuthorizationCodeOptions::new(), + ) + .unwrap(); + + let err = source.oidc.refresh_token("still-valid").await.unwrap_err(); + assert!(matches!( + err, + Error::Runtime { message } + if message.contains("503 Service Unavailable") && message.contains("transient") + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_token_request_rejects_insecure_redirect() { + let (issuer_url, server) = spawn_redirecting_token_server().await; + let source = ClientCredentialsSource::new( + issuer_url, + "client-id".to_string(), + Some("secret".to_string()), + ClientAuthMethod::ClientSecretBasic, + vec!["scope".to_string()], + None, + None, + ) + .unwrap(); + + let err = TokenSource::fetch_token(&source).await.unwrap_err(); + let Error::Runtime { message } = &err else { + panic!("expected runtime error, got {err:?}"); + }; + assert!(message.contains("redirect")); + // The insecure redirect target must never be contacted. + assert!(!message.contains("idp.example.com")); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_malformed_token_response_error_does_not_leak_body() { + let (issuer_url, server) = spawn_malformed_token_server().await; + let source = ClientCredentialsSource::new( + issuer_url, + "client-id".to_string(), + Some("secret".to_string()), + ClientAuthMethod::ClientSecretBasic, + vec!["scope".to_string()], + None, + None, + ) + .unwrap(); + + let err = TokenSource::fetch_token(&source).await.unwrap_err(); + let message = format!("{err:?}"); + assert!(!message.contains("leak-marker")); + assert!(matches!( + err, + Error::Runtime { message } + if message.contains("could not be parsed") || message.contains("Content-Type") + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_device_authorization_polls_until_success() { + let (issuer_url, token_requests, server) = spawn_device_server().await; + let source = DeviceCodeSource::new( + issuer_url, + "client-id".to_string(), + Some("secret".to_string()), + ClientAuthMethod::ClientSecretBasic, + vec!["openid".to_string()], + None, + None, + ) + .unwrap(); + + let device = source.request_device_authorization().await.unwrap(); + let response = source.poll_for_token(&device).await.unwrap(); + + assert_eq!(response.access_token.secret(), "device-token"); + assert_eq!( + response + .refresh_token() + .map(|token| token.secret().as_str()), + Some("device-refresh") + ); + assert_eq!(token_requests.load(Ordering::SeqCst), 3); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_device_authorization_rejects_plaintext_verification_uri() { + let (issuer_url, server) = spawn_insecure_device_verification_server().await; + let source = DeviceCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + ) + .unwrap(); + + let Err(err) = source.request_device_authorization().await else { + panic!("expected insecure verification URI to be rejected"); + }; + assert!(matches!( + err, + Error::InvalidInput { message } + if message.contains("verification_uri must use https") + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_device_authorization_retries_transient_failures() { + let (issuer_url, token_requests, server) = spawn_device_transient_server().await; + let source = DeviceCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + ) + .unwrap(); + let device = test_device_authorization_response(60, 1); + + let response = source.poll_for_token(&device).await.unwrap(); + + assert_eq!(response.access_token.secret(), "device-token"); + assert_eq!(token_requests.load(Ordering::SeqCst), 4); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_device_authorization_reports_access_denied() { + let (issuer_url, server) = spawn_device_error_server("access_denied").await; + let source = DeviceCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + ) + .unwrap(); + let device = test_device_authorization_response(60, 1); + + let err = source.poll_for_token(&device).await.unwrap_err(); + assert!(matches!( + err, + Error::Runtime { message } + if message == "Device authorization was denied by the user" + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_device_authorization_reports_provider_expiry() { + let (issuer_url, server) = spawn_device_error_server("expired_token").await; + let source = DeviceCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + ) + .unwrap(); + let device = test_device_authorization_response(60, 1); + + let err = source.poll_for_token(&device).await.unwrap_err(); + assert!(matches!( + err, + Error::Runtime { message } + if message == "Device authorization expired before authentication completed" + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn test_device_authorization_stops_at_local_deadline() { + let (issuer_url, server) = spawn_discovery_server(1).await; + let source = DeviceCodeSource::new( + issuer_url, + "client-id".to_string(), + None, + ClientAuthMethod::None, + vec!["openid".to_string()], + None, + None, + ) + .unwrap(); + let device = test_device_authorization_response(1, 1); + + let err = source.poll_for_token(&device).await.unwrap_err(); + assert!(matches!( + err, + Error::Runtime { message } + if message == "Device authorization expired before authentication completed" + )); + server.await.unwrap(); + } + + #[derive(Debug)] + struct RefreshingTokenSource { + fetches: Arc, + refreshes: Arc, + } + + #[async_trait] + impl TokenSource for RefreshingTokenSource { + async fn fetch_token(&self) -> Result { + self.fetches.fetch_add(1, Ordering::SeqCst); + Ok(token_response("initial", Some("refresh"), Some(3600))) + } + + async fn refresh_token(&self, refresh_token: &str) -> Result { + assert_eq!(refresh_token, "refresh"); + self.refreshes.fetch_add(1, Ordering::SeqCst); + Ok(RefreshResult::Refreshed(token_response( + "refreshed", + None, + Some(3600), + ))) + } + } + + #[tokio::test] + async fn test_header_provider_uses_and_retains_refresh_token() { + let fetches = Arc::new(AtomicUsize::new(0)); + let refreshes = Arc::new(AtomicUsize::new(0)); + let provider = OAuthHeaderProvider { + token_source: Box::new(RefreshingTokenSource { + fetches: Arc::clone(&fetches), + refreshes: Arc::clone(&refreshes), + }), + token_state: Arc::new(RwLock::new(TokenState::new())), + refresh_buffer: Duration::ZERO, + token_cache: None, + }; + + assert_eq!(provider.get_valid_token().await.unwrap(), "initial"); + provider.token_state.write().await.expires_at = + Some(Instant::now() - Duration::from_secs(1)); + assert_eq!(provider.get_valid_token().await.unwrap(), "refreshed"); + assert_eq!(fetches.load(Ordering::SeqCst), 1); + assert_eq!(refreshes.load(Ordering::SeqCst), 1); + assert_eq!( + provider.token_state.read().await.refresh_token.as_deref(), + Some("refresh") + ); + } + + #[derive(Debug)] + struct FailedRefreshTokenSource { + fetches: Arc, + refreshes: Arc, + } + + #[async_trait] + impl TokenSource for FailedRefreshTokenSource { + async fn fetch_token(&self) -> Result { + self.fetches.fetch_add(1, Ordering::SeqCst); + Ok(token_response( + "reauthenticated", + Some("new-refresh"), + Some(3600), + )) + } + + async fn refresh_token(&self, refresh_token: &str) -> Result { + assert_eq!(refresh_token, "revoked-refresh"); + self.refreshes.fetch_add(1, Ordering::SeqCst); + Ok(RefreshResult::Reauthenticate) + } + } + + #[tokio::test] + async fn test_header_provider_reauthenticates_after_refresh_failure() { + let fetches = Arc::new(AtomicUsize::new(0)); + let refreshes = Arc::new(AtomicUsize::new(0)); + let provider = OAuthHeaderProvider { + token_source: Box::new(FailedRefreshTokenSource { + fetches: Arc::clone(&fetches), + refreshes: Arc::clone(&refreshes), + }), + token_state: Arc::new(RwLock::new(TokenState { + access_token: Some("expired".to_string()), + refresh_token: Some("revoked-refresh".to_string()), + expires_at: Some(Instant::now() - Duration::from_secs(1)), + })), + refresh_buffer: Duration::ZERO, + token_cache: None, + }; + + assert_eq!(provider.get_valid_token().await.unwrap(), "reauthenticated"); + assert_eq!(fetches.load(Ordering::SeqCst), 1); + assert_eq!(refreshes.load(Ordering::SeqCst), 1); + assert_eq!( + provider.token_state.read().await.refresh_token.as_deref(), + Some("new-refresh") + ); + } + + #[derive(Debug)] + struct TransientRefreshFailureSource { + fetches: Arc, + } + + #[async_trait] + impl TokenSource for TransientRefreshFailureSource { + async fn fetch_token(&self) -> Result { + self.fetches.fetch_add(1, Ordering::SeqCst); + unreachable!("a transient refresh failure must not start an interactive flow") + } + + async fn refresh_token(&self, refresh_token: &str) -> Result { + assert_eq!(refresh_token, "valid-refresh"); + Err(Error::Runtime { + message: "token endpoint temporarily unavailable".to_string(), + }) + } + } + + #[tokio::test] + async fn test_header_provider_preserves_refresh_token_after_transient_failure() { + let fetches = Arc::new(AtomicUsize::new(0)); + let provider = OAuthHeaderProvider { + token_source: Box::new(TransientRefreshFailureSource { + fetches: Arc::clone(&fetches), + }), + token_state: Arc::new(RwLock::new(TokenState { + access_token: Some("expired".to_string()), + refresh_token: Some("valid-refresh".to_string()), + expires_at: Some(Instant::now() - Duration::from_secs(1)), + })), + refresh_buffer: Duration::ZERO, + token_cache: None, + }; + + let err = provider.get_valid_token().await.unwrap_err(); + assert!(matches!( + err, + Error::Runtime { message } + if message == "token endpoint temporarily unavailable" + )); + assert_eq!(fetches.load(Ordering::SeqCst), 0); + assert_eq!( + provider.token_state.read().await.refresh_token.as_deref(), + Some("valid-refresh") + ); + } + + fn test_config(flow: OAuthFlow, client_secret: Option) -> OAuthConfig { + OAuthConfig { + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + client_secret, + scopes: vec!["scope".to_string()], + resource: None, + audience: None, + flow, + client_auth_method: None, + refresh_buffer_secs: None, + token_cache: None, + } } #[test] fn test_oauth_config_debug_redacts_client_secret() { - let config = OAuthConfig { - issuer_url: "https://issuer.example.com".to_string(), - client_id: "client-id".to_string(), - client_secret: Some("super-secret".to_string()), - scopes: vec!["scope".to_string()], - flow: OAuthFlow::ClientCredentials, - refresh_buffer_secs: None, - }; + let mut config = test_config( + OAuthFlow::ClientCredentials, + Some("super-secret".to_string()), + ); + config.client_auth_method = Some(ClientAuthMethod::ClientSecretBasic); let debug = format!("{config:?}"); assert!(!debug.contains("super-secret")); assert!(debug.contains("client_secret: Some(\"\")")); + assert!(debug.contains("client_auth_method")); } #[test] fn test_oauth_header_provider_debug_redacts_client_secret() { - let config = OAuthConfig { - issuer_url: "https://issuer.example.com".to_string(), - client_id: "client-id".to_string(), - client_secret: Some("super-secret".to_string()), - scopes: vec!["scope".to_string()], - flow: OAuthFlow::ClientCredentials, - refresh_buffer_secs: None, - }; + let config = test_config( + OAuthFlow::ClientCredentials, + Some("super-secret".to_string()), + ); let provider = OAuthHeaderProvider::new(config).unwrap(); let debug = format!("{provider:?}"); assert!(!debug.contains("super-secret")); - assert!(debug.contains("client_secret: \"\"")); + assert!(debug.contains("client_secret: Some(\"\")")); } #[test] @@ -701,8 +2992,12 @@ mod tests { "api://test-a/.default".to_string(), "api://test-b/.default".to_string(), ], + resource: None, + audience: None, flow: OAuthFlow::AzureManagedIdentity { client_id: None }, + client_auth_method: None, refresh_buffer_secs: None, + token_cache: None, }; assert!(OAuthHeaderProvider::new(config).is_err()); } @@ -714,11 +3009,14 @@ mod tests { issuer_url, "client-id".to_string(), Some("secret".to_string()), + ClientAuthMethod::ClientSecretBasic, vec!["scope".to_string()], + None, + None, ) .unwrap(); - let err = source.get_token_endpoint().await.unwrap_err(); + let err = source.oidc.get_token_endpoint().await.unwrap_err(); assert!(matches!( err, Error::Runtime { message } @@ -729,46 +3027,28 @@ mod tests { #[test] fn test_client_credentials_requires_secret() { - let config = OAuthConfig { - issuer_url: "https://login.microsoftonline.com/tenant/v2.0".to_string(), - client_id: "app-id".to_string(), - client_secret: None, - scopes: vec!["scope".to_string()], - flow: OAuthFlow::ClientCredentials, - refresh_buffer_secs: None, - }; + let config = test_config(OAuthFlow::ClientCredentials, None); assert!(OAuthHeaderProvider::new(config).is_err()); } #[test] fn test_client_credentials_rejects_insecure_non_loopback_issuer() { - let config = OAuthConfig { - issuer_url: "http://issuer.example.com".to_string(), - client_id: "app-id".to_string(), - client_secret: Some("secret".to_string()), - scopes: vec!["scope".to_string()], - flow: OAuthFlow::ClientCredentials, - refresh_buffer_secs: None, - }; + let mut config = test_config(OAuthFlow::ClientCredentials, Some("secret".to_string())); + config.issuer_url = "http://issuer.example.com".to_string(); let err = OAuthHeaderProvider::new(config).unwrap_err(); assert!(matches!( err, Error::InvalidInput { message } - if message == "ClientCredentials OAuth issuer_url must use https, except for loopback hosts" + if message + == "OAuth issuer_url must use https, except for http on a loopback host" )); } #[test] fn test_empty_scopes_rejected() { - let config = OAuthConfig { - issuer_url: "https://login.microsoftonline.com/tenant/v2.0".to_string(), - client_id: "app-id".to_string(), - client_secret: None, - scopes: vec![], - flow: OAuthFlow::AzureManagedIdentity { client_id: None }, - refresh_buffer_secs: None, - }; + let mut config = test_config(OAuthFlow::AzureManagedIdentity { client_id: None }, None); + config.scopes = vec![]; assert!(OAuthHeaderProvider::new(config).is_err()); } @@ -780,8 +3060,12 @@ mod tests { client_id: "client-id".to_string(), client_secret: Some("secret".to_string()), scopes: vec!["scope".to_string()], + resource: None, + audience: None, flow: OAuthFlow::ClientCredentials, + client_auth_method: None, refresh_buffer_secs: Some(0), + token_cache: None, }; let provider = OAuthHeaderProvider::new(config).unwrap(); @@ -803,6 +3087,409 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn test_client_credentials_supports_client_secret_post() { + let (issuer_url, request, server) = spawn_captured_token_server().await; + let config = OAuthConfig { + issuer_url, + client_id: "client-id".to_string(), + client_secret: Some("secret".to_string()), + scopes: vec!["scope".to_string()], + resource: None, + audience: None, + flow: OAuthFlow::ClientCredentials, + client_auth_method: Some(ClientAuthMethod::ClientSecretPost), + refresh_buffer_secs: None, + token_cache: None, + }; + let provider = OAuthHeaderProvider::new(config).unwrap(); + + provider.get_headers().await.unwrap(); + + let request = request.lock().unwrap().take().unwrap(); + assert_eq!(request.header("authorization"), None); + assert!(request.body.contains("grant_type=client_credentials")); + assert!(request.body.contains("client_id=client-id")); + assert!(request.body.contains("client_secret=secret")); + assert!(request.body.contains("scope=scope")); + server.await.unwrap(); + } + + async fn spawn_discovery_server(expected_requests: usize) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + + let server = tokio::spawn(async move { + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + assert!( + request + .line + .starts_with("GET /.well-known/openid-configuration ") + ); + let discovery = format!( + r#"{{"token_endpoint":"http://{addr}/token","authorization_endpoint":"http://{addr}/authorize","device_authorization_endpoint":"http://{addr}/device"}}"# + ); + write_json_response(&mut stream, "200 OK", &discovery).await; + } + }); + + (issuer_url, server) + } + + async fn spawn_insecure_authorization_discovery_server() -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + assert!( + request + .line + .starts_with("GET /.well-known/openid-configuration ") + ); + write_json_response( + &mut stream, + "200 OK", + r#"{"token_endpoint":"https://idp.example.com/token","authorization_endpoint":"http://idp.example.com/authorize"}"#, + ) + .await; + }); + + (issuer_url, server) + } + + async fn spawn_insecure_device_verification_server() -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + if request + .line + .starts_with("GET /.well-known/openid-configuration ") + { + let discovery = format!( + r#"{{"token_endpoint":"http://{addr}/token","device_authorization_endpoint":"http://{addr}/device"}}"# + ); + write_json_response(&mut stream, "200 OK", &discovery).await; + } else { + assert!(request.line.starts_with("POST /device ")); + write_json_response( + &mut stream, + "200 OK", + r#"{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://idp.example.com/device","expires_in":60}"#, + ) + .await; + } + } + }); + + (issuer_url, server) + } + + async fn spawn_captured_token_server() -> ( + String, + Arc>>, + JoinHandle<()>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + let request = Arc::new(std::sync::Mutex::new(None)); + let server_request = Arc::clone(&request); + + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let captured = read_http_request(&mut stream).await; + if captured + .line + .starts_with("GET /.well-known/openid-configuration ") + { + let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#); + write_json_response(&mut stream, "200 OK", &discovery).await; + } else if captured.line.starts_with("POST /token ") { + *server_request.lock().unwrap() = Some(captured); + write_json_response( + &mut stream, + "200 OK", + r#"{"access_token":"token","refresh_token":"refresh","expires_in":3600,"token_type":"Bearer"}"#, + ) + .await; + } else { + write_json_response(&mut stream, "404 Not Found", "{}").await; + } + } + }); + + (issuer_url, request, server) + } + + async fn spawn_redirecting_token_server() -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + if request + .line + .starts_with("GET /.well-known/openid-configuration ") + { + let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#); + write_json_response(&mut stream, "200 OK", &discovery).await; + } else { + assert!(request.line.starts_with("POST /token ")); + let response = "HTTP/1.1 302 Found\r\nlocation: http://idp.example.com/steal\r\ncontent-length: 0\r\nconnection: close\r\n\r\n".to_string(); + stream.write_all(response.as_bytes()).await.unwrap(); + } + } + }); + + (issuer_url, server) + } + + async fn spawn_malformed_token_server() -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + if request + .line + .starts_with("GET /.well-known/openid-configuration ") + { + let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#); + write_json_response(&mut stream, "200 OK", &discovery).await; + } else { + assert!(request.line.starts_with("POST /token ")); + write_json_response( + &mut stream, + "200 OK", + r#"{"access_token":{"nested":"leak-marker-12345"}}"#, + ) + .await; + } + } + }); + + (issuer_url, server) + } + + async fn spawn_refresh_error_server( + status: &'static str, + response_body: &'static str, + ) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + if request + .line + .starts_with("GET /.well-known/openid-configuration ") + { + let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#); + write_json_response(&mut stream, "200 OK", &discovery).await; + } else if request.line.starts_with("POST /token ") { + assert!(request.body.contains("grant_type=refresh_token")); + assert!(request.body.contains("refresh_token=")); + write_json_response(&mut stream, status, response_body).await; + } else { + write_json_response(&mut stream, "404 Not Found", "{}").await; + } + } + }); + + (issuer_url, server) + } + + async fn spawn_device_server() -> (String, Arc, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + let token_requests = Arc::new(AtomicUsize::new(0)); + let server_token_requests = Arc::clone(&token_requests); + + let server = tokio::spawn(async move { + for _ in 0..5 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + if request + .line + .starts_with("GET /.well-known/openid-configuration ") + { + let discovery = format!( + r#"{{"token_endpoint":"http://{addr}/token","device_authorization_endpoint":"http://{addr}/device"}}"# + ); + write_json_response(&mut stream, "200 OK", &discovery).await; + } else if request.line.starts_with("POST /device ") { + // The resolved default for a confidential client is HTTP Basic. + assert_eq!( + request.header("authorization").as_deref(), + Some(basic_authorization("client-id", "secret").as_str()) + ); + assert!(request.body.contains("scope=openid")); + assert!(!request.body.contains("client_secret")); + let device = format!( + r#"{{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://{addr}/verify","verification_uri_complete":"http://{addr}/verify?user_code=ABCD-EFGH","expires_in":60,"interval":1}}"# + ); + write_json_response(&mut stream, "200 OK", &device).await; + } else if request.line.starts_with("POST /token ") { + assert_eq!( + request.header("authorization").as_deref(), + Some(basic_authorization("client-id", "secret").as_str()) + ); + assert!(request.body.contains( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" + )); + assert!(request.body.contains("device_code=device-code")); + assert!(!request.body.contains("client_secret")); + let request = server_token_requests.fetch_add(1, Ordering::SeqCst); + match request { + 0 => { + write_json_response( + &mut stream, + "400 Bad Request", + r#"{"error":"authorization_pending"}"#, + ) + .await; + } + 1 => { + write_json_response( + &mut stream, + "400 Bad Request", + r#"{"error":"slow_down"}"#, + ) + .await; + } + _ => { + write_json_response( + &mut stream, + "200 OK", + r#"{"access_token":"device-token","refresh_token":"device-refresh","expires_in":3600,"token_type":"Bearer"}"#, + ) + .await; + } + } + } else { + write_json_response(&mut stream, "404 Not Found", "{}").await; + } + } + }); + + (issuer_url, token_requests, server) + } + + async fn spawn_device_transient_server() -> (String, Arc, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + let token_requests = Arc::new(AtomicUsize::new(0)); + let server_token_requests = Arc::clone(&token_requests); + + let server = tokio::spawn(async move { + for _ in 0..5 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + if request + .line + .starts_with("GET /.well-known/openid-configuration ") + { + let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#); + write_json_response(&mut stream, "200 OK", &discovery).await; + continue; + } + + assert!(request.line.starts_with("POST /token ")); + match server_token_requests.fetch_add(1, Ordering::SeqCst) { + 0 => drop(stream), + 1 => { + write_json_response( + &mut stream, + "503 Service Unavailable", + r#"{"error":"server_error"}"#, + ) + .await; + } + 2 => { + write_json_response( + &mut stream, + "400 Bad Request", + r#"{"error":"temporarily_unavailable"}"#, + ) + .await; + } + _ => { + write_json_response( + &mut stream, + "200 OK", + r#"{"access_token":"device-token","expires_in":3600,"token_type":"Bearer"}"#, + ) + .await; + } + } + } + }); + + (issuer_url, token_requests, server) + } + + fn test_device_authorization_response( + expires_in: u64, + interval: u64, + ) -> StandardDeviceAuthorizationResponse { + serde_json::from_str(&format!( + r#"{{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://127.0.0.1/verify","expires_in":{expires_in},"interval":{interval}}}"# + )) + .unwrap() + } + + async fn spawn_device_error_server(error: &'static str) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + if request + .line + .starts_with("GET /.well-known/openid-configuration ") + { + let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#); + write_json_response(&mut stream, "200 OK", &discovery).await; + } else if request.line.starts_with("POST /token ") { + write_json_response( + &mut stream, + "400 Bad Request", + &format!(r#"{{"error":"{error}"}}"#), + ) + .await; + } else { + write_json_response(&mut stream, "404 Not Found", "{}").await; + } + } + }); + + (issuer_url, server) + } + async fn spawn_oauth_server() -> (String, Arc, JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -813,16 +3500,23 @@ mod tests { let server = tokio::spawn(async move { for _ in 0..3 { let (mut stream, _) = listener.accept().await.unwrap(); - let (request_line, body) = read_http_request(&mut stream).await; + let request = read_http_request(&mut stream).await; - if request_line.starts_with("GET /.well-known/openid-configuration ") { + if request + .line + .starts_with("GET /.well-known/openid-configuration ") + { let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#); write_json_response(&mut stream, "200 OK", &discovery).await; - } else if request_line.starts_with("POST /token ") { - assert!(body.contains("grant_type=client_credentials")); - assert!(body.contains("client_id=client-id")); - assert!(body.contains("client_secret=secret")); - assert!(body.contains("scope=scope")); + } else if request.line.starts_with("POST /token ") { + assert_eq!( + request.header("authorization").as_deref(), + Some(basic_authorization("client-id", "secret").as_str()) + ); + assert!(request.body.contains("grant_type=client_credentials")); + assert!(request.body.contains("scope=scope")); + assert!(!request.body.contains("client_secret")); + assert!(!request.body.contains("client_id")); let token_num = server_token_requests.fetch_add(1, Ordering::SeqCst) + 1; let token = format!( @@ -845,15 +3539,19 @@ mod tests { let server = tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); - let (request_line, _) = read_http_request(&mut stream).await; - assert!(request_line.starts_with("GET /.well-known/openid-configuration ")); + let request = read_http_request(&mut stream).await; + assert!( + request + .line + .starts_with("GET /.well-known/openid-configuration ") + ); write_json_response(&mut stream, "503 Service Unavailable", "{}").await; }); (issuer_url, server) } - async fn read_http_request(stream: &mut TcpStream) -> (String, String) { + async fn read_http_request(stream: &mut TcpStream) -> CapturedRequest { let mut buffer = Vec::new(); let mut header_end = None; @@ -867,7 +3565,7 @@ mod tests { let header_end = header_end.unwrap(); let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string(); - let request_line = headers.lines().next().unwrap_or_default().to_string(); + let line = headers.lines().next().unwrap_or_default().to_string(); let content_length = headers .lines() .find_map(|line| { @@ -888,7 +3586,11 @@ mod tests { let body = String::from_utf8_lossy(&buffer[header_end..header_end + content_length]).to_string(); - (request_line, body) + CapturedRequest { + line, + headers, + body, + } } fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { diff --git a/rust/lancedb/src/remote/sql.rs b/rust/lancedb/src/remote/sql.rs new file mode 100644 index 000000000..489158922 --- /dev/null +++ b/rust/lancedb/src/remote/sql.rs @@ -0,0 +1,1471 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::collections::HashMap; +use std::fs; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::{Duration, Instant}; + +use arrow_array::RecordBatch; +use arrow_flight::decode::FlightRecordBatchStream; +use arrow_flight::error::FlightError; +use arrow_flight::flight_service_client::FlightServiceClient; +use arrow_flight::sql::{CommandStatementQuery, ProstMessageExt}; +use arrow_flight::{ + Action, CancelFlightInfoRequest, CancelFlightInfoResult, CancelStatus, FlightClient, + FlightDescriptor, FlightEndpoint, FlightInfo, PollInfo, +}; +use arrow_schema::{Schema, SchemaRef}; +use futures::TryStreamExt; +use http::header::{HeaderMap, HeaderName, HeaderValue}; +use prost::Message; +use tokio::sync::{Mutex, Notify, OnceCell, mpsc}; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; +use uuid::Uuid; + +use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream}; +use crate::error::{Error, Result}; +use crate::remote::client::{ClientConfig, TlsConfig}; +use crate::remote::retry::ResolvedRetryConfig; +use crate::sql::{Query, QueryDescription, QueryHandle, QueryStatus}; + +const DEFAULT_SQL_PORT: u16 = 10025; +const DEFAULT_SQL_TLS_PORT: u16 = 10026; +const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(120); +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(300); +const STATUS_POLL_TIMEOUT: Duration = Duration::from_secs(1); +const MIN_POLL_INTERVAL: Duration = Duration::from_millis(50); +const MAX_SQL_MESSAGE_SIZE: usize = 1024 * 1024 * 1024; +const TERMINAL_QUERY_RETENTION: Duration = Duration::from_secs(300); +const ABANDONED_QUERY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(Clone)] +pub(super) struct SqlClient { + inner: Arc, + queries: Arc, +} + +struct SqlClientInner { + database: String, + database_prefix: Option, + api_key: String, + host_override: Option, + sql_host_override: Option, + client_config: ClientConfig, + client: Arc>, +} + +struct SqlConnection { + // FlightClient does not expose its transport. Cancellation retains the channel so it can + // install a per-call interceptor that records whether a request was dispatched. + channel: Channel, + client: FlightServiceClient, +} + +struct ResultEndpointStream { + stream: FlightRecordBatchStream, + request_id: String, + read_timeout: Duration, +} + +struct PreparedSqlResult { + schema: SchemaRef, + next_endpoint: usize, + endpoint_stream: Option, + buffered_batch: Option, +} + +impl ResultEndpointStream { + async fn next_batch(&mut self) -> Result> { + tokio::time::timeout(self.read_timeout, self.stream.try_next()) + .await + .map_err(|_| sql_error(&self.request_id, "SQL result read timed out"))? + .map_err(|err| sql_error(&self.request_id, err)) + } +} + +enum CancelOutcome { + Status(CancelStatus), + NotFound(String), +} + +struct CancelAttempt { + dispatched: Arc, + unresolved: Arc, + resolved: bool, +} + +struct ResultStartGuard<'a> { + started: &'a AtomicBool, + committed: bool, +} + +impl<'a> ResultStartGuard<'a> { + fn new(started: &'a AtomicBool) -> Self { + Self { + started, + committed: false, + } + } + + fn commit(mut self) { + self.committed = true; + } +} + +impl Drop for ResultStartGuard<'_> { + fn drop(&mut self) { + if !self.committed { + self.started.store(false, Ordering::SeqCst); + } + } +} + +impl CancelAttempt { + fn new(dispatched: Arc, unresolved: Arc) -> Self { + Self { + dispatched, + unresolved, + resolved: false, + } + } + + fn resolve(&mut self) { + self.resolved = true; + } +} + +impl Drop for CancelAttempt { + fn drop(&mut self) { + if !self.resolved && self.dispatched.load(Ordering::SeqCst) { + self.unresolved.store(true, Ordering::SeqCst); + } + } +} + +impl std::fmt::Debug for SqlClient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SqlClient") + .field("database", &self.inner.database) + .field("database_prefix", &self.inner.database_prefix) + .field("api_key", &"") + .field("host_override", &self.inner.host_override) + .field("sql_host_override", &self.inner.sql_host_override) + .field("client_config", &"") + .field("initialized", &self.inner.client.get().is_some()) + .finish() + } +} + +impl SqlClient { + pub(super) fn new( + database: String, + database_prefix: Option, + api_key: String, + host_override: Option, + sql_host_override: Option, + client_config: ClientConfig, + ) -> Self { + Self { + inner: Arc::new(SqlClientInner { + database, + database_prefix, + api_key, + host_override, + sql_host_override, + client_config, + client: Arc::new(OnceCell::new()), + }), + queries: Arc::new(QueryRegistry::new()), + } + } + + pub(super) async fn submit( + &self, + query: &str, + default_namespace_path: &[String], + ) -> Result { + let timeout = self.inner.overall_timeout()?; + with_overall_timeout(timeout, "SQL query submission", async { + validate_namespace_path(default_namespace_path)?; + let command = CommandStatementQuery { + query: query.to_string(), + transaction_id: None, + }; + let descriptor = FlightDescriptor::new_cmd(command.as_any().encode_to_vec()); + let poll_info = self.inner.poll(descriptor, default_namespace_path).await?; + let query_id = Uuid::now_v7(); + let query = Arc::new(RemoteQuery::new( + query_id, + self.inner.clone(), + default_namespace_path.to_vec(), + poll_info, + )?); + self.queries.insert(query_id, query.clone()); + Ok(Query::new(Arc::new(RemoteQueryHandle::new(query)))) + }) + .await + } + + pub(super) async fn describe(&self, query_id: Uuid) -> Result { + let query = self + .queries + .get(query_id) + .ok_or_else(|| Error::InvalidInput { + message: "Unknown or expired SQL query id for this connection".to_string(), + })?; + query.describe().await + } + + #[cfg(test)] + async fn initialized_client_count(&self) -> usize { + usize::from(self.inner.client.get().is_some()) + } +} + +impl SqlClientInner { + fn overall_timeout(&self) -> Result> { + resolve_timeout( + self.client_config.timeout_config.timeout, + "LANCE_CLIENT_TIMEOUT", + None, + ) + } + + async fn poll( + &self, + descriptor: FlightDescriptor, + default_namespace_path: &[String], + ) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + let read_timeout = resolve_timeout( + self.client_config.timeout_config.read_timeout, + "LANCE_CLIENT_READ_TIMEOUT", + Some(DEFAULT_READ_TIMEOUT), + )? + .unwrap(); + let mut client = self + .client_with_headers(default_namespace_path, &request_id) + .await?; + tokio::time::timeout(read_timeout, client.poll_flight_info(descriptor)) + .await + .map_err(|_| sql_error(&request_id, "SQL query poll timed out"))? + .map_err(|err| sql_error(&request_id, err)) + } + + async fn poll_status( + &self, + descriptor: FlightDescriptor, + default_namespace_path: &[String], + ) -> Result> { + let request_id = uuid::Uuid::new_v4().to_string(); + let mut client = self + .client_with_headers(default_namespace_path, &request_id) + .await + .map_err(|err| sql_error(&request_id, err))?; + match tokio::time::timeout(STATUS_POLL_TIMEOUT, client.poll_flight_info(descriptor)).await { + Ok(result) => result.map(Some).map_err(|err| sql_error(&request_id, err)), + Err(_) => Ok(None), + } + } + + async fn poll_continuation( + &self, + descriptor: FlightDescriptor, + default_namespace_path: &[String], + ) -> Result { + let read_timeout = resolve_timeout( + self.client_config.timeout_config.read_timeout, + "LANCE_CLIENT_READ_TIMEOUT", + Some(DEFAULT_READ_TIMEOUT), + )? + .unwrap(); + let retry_config = ResolvedRetryConfig::try_from(self.client_config.retry_config.clone())?; + let mut retry_count = 0_u8; + loop { + let started = Instant::now(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut client = self + .client_with_headers(default_namespace_path, &request_id) + .await?; + let result = + tokio::time::timeout(read_timeout, client.poll_flight_info(descriptor.clone())) + .await; + let poll_info = match result { + Err(_) if retry_count < retry_config.read_retries => { + retry_count += 1; + tokio::time::sleep(poll_retry_delay(&retry_config, retry_count)).await; + continue; + } + Err(_) => return Err(sql_error(&request_id, "SQL query poll timed out")), + Ok(Err(FlightError::Tonic(status))) + if matches!( + status.code(), + tonic::Code::DeadlineExceeded | tonic::Code::Unavailable + ) && retry_count < retry_config.read_retries => + { + retry_count += 1; + tokio::time::sleep(poll_retry_delay(&retry_config, retry_count)).await; + continue; + } + Ok(Err(error)) => return Err(sql_error(&request_id, error)), + Ok(Ok(poll_info)) => poll_info, + }; + if let Some(delay) = MIN_POLL_INTERVAL.checked_sub(started.elapsed()) { + tokio::time::sleep(delay).await; + } + return Ok(poll_info); + } + } + + async fn open_result_endpoint( + &self, + endpoint: FlightEndpoint, + default_namespace_path: &[String], + ) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + let read_timeout = resolve_timeout( + self.client_config.timeout_config.read_timeout, + "LANCE_CLIENT_READ_TIMEOUT", + Some(DEFAULT_READ_TIMEOUT), + )? + .unwrap(); + let ticket = endpoint.ticket.ok_or_else(|| { + sql_error(&request_id, "SQL result endpoint did not include a ticket") + })?; + let mut endpoint_client = self + .client_with_headers(default_namespace_path, &request_id) + .await?; + let stream = tokio::time::timeout(read_timeout, endpoint_client.do_get(ticket)) + .await + .map_err(|_| sql_error(&request_id, "SQL result fetch timed out"))? + .map_err(|err| sql_error(&request_id, err))?; + Ok(ResultEndpointStream { + stream, + request_id, + read_timeout, + }) + } + + async fn cancel( + &self, + info: FlightInfo, + default_namespace_path: &[String], + unresolved_attempt: Arc, + ) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + let read_timeout = resolve_timeout( + self.client_config.timeout_config.read_timeout, + "LANCE_CLIENT_READ_TIMEOUT", + Some(DEFAULT_READ_TIMEOUT), + )? + .unwrap(); + let connection = self.connection(&request_id).await?; + let headers = self.headers(default_namespace_path, &request_id).await?; + let metadata = client_with_headers(connection.client.clone(), &headers)? + .metadata() + .clone(); + let dispatched = Arc::new(AtomicBool::new(false)); + let mut attempt = CancelAttempt::new(dispatched.clone(), unresolved_attempt); + let mut client = FlightServiceClient::with_interceptor( + connection.channel.clone(), + move |request: tonic::Request<()>| { + dispatched.store(true, Ordering::SeqCst); + Ok(request) + }, + ) + .max_decoding_message_size(MAX_SQL_MESSAGE_SIZE); + let action = Action::new( + "CancelFlightInfo", + CancelFlightInfoRequest::new(info).encode_to_vec(), + ); + let mut request = tonic::Request::new(action); + *request.metadata_mut() = metadata; + let result = tokio::time::timeout(read_timeout, async { + let response = client + .do_action(request) + .await + .map_err(|status| FlightError::Tonic(Box::new(status)))?; + let response = response + .into_inner() + .message() + .await + .map_err(|status| FlightError::Tonic(Box::new(status)))? + .ok_or_else(|| { + FlightError::protocol("Received no response for cancel_flight_info call") + })?; + CancelFlightInfoResult::decode(response.body) + .map_err(|err| FlightError::DecodeError(err.to_string())) + }) + .await + .map_err(|_| sql_error(&request_id, "SQL query cancellation timed out"))?; + let result = match result { + Ok(result) => result, + Err(FlightError::Tonic(status)) if status.code() == tonic::Code::NotFound => { + attempt.resolve(); + return Ok(CancelOutcome::NotFound(request_id)); + } + Err(FlightError::Tonic(status)) if !cancellation_status_is_ambiguous(status.code()) => { + attempt.resolve(); + return Err(sql_error(&request_id, status)); + } + Err(error) => return Err(sql_error(&request_id, error)), + }; + let status = CancelStatus::try_from(result.status) + .map_err(|_| sql_error(&request_id, "SQL query returned an invalid cancel status"))?; + if status != CancelStatus::Unspecified { + attempt.resolve(); + } + Ok(CancelOutcome::Status(status)) + } + + async fn client_with_headers( + &self, + default_namespace_path: &[String], + request_id: &str, + ) -> Result { + let connection = self.connection(request_id).await?; + let headers = self.headers(default_namespace_path, request_id).await?; + client_with_headers(connection.client.clone(), &headers) + } + + async fn connection(&self, request_id: &str) -> Result<&SqlConnection> { + self.client + .get_or_try_init(|| async { + let target = resolve_sql_host_override( + self.host_override.as_deref(), + self.sql_host_override.as_deref(), + )?; + let channel = connect_channel(&target, &self.client_config, request_id).await?; + let client = FlightServiceClient::new(channel.clone()) + .max_decoding_message_size(MAX_SQL_MESSAGE_SIZE); + Ok::<_, Error>(SqlConnection { channel, client }) + }) + .await + } + + async fn headers( + &self, + default_namespace_path: &[String], + request_id: &str, + ) -> Result { + let mut headers = HeaderMap::new(); + merge_headers(&mut headers, &self.client_config.extra_headers)?; + if let Some(provider) = &self.client_config.header_provider { + merge_headers(&mut headers, &provider.get_headers().await?)?; + } + + let has_authorization = headers.contains_key("authorization"); + let has_api_key = headers.contains_key("x-api-key"); + if has_authorization && has_api_key { + return Err(Error::InvalidInput { + message: "SQL accepts either authorization or x-api-key, not both".to_string(), + }); + } + if !has_authorization && !has_api_key { + if self.api_key.is_empty() { + return Err(Error::InvalidInput { + message: "SQL authentication credentials are required".to_string(), + }); + } + insert_header(&mut headers, "x-api-key", &self.api_key)?; + } + + insert_header(&mut headers, "database", &self.database)?; + if let Some(database_prefix) = &self.database_prefix { + insert_header(&mut headers, "x-lancedb-database-prefix", database_prefix)?; + } + let namespace_path = if default_namespace_path.is_empty() { + "public".to_string() + } else { + default_namespace_path.join("$") + }; + insert_header(&mut headers, "namespace-path", &namespace_path)?; + insert_header(&mut headers, "x-request-id", request_id)?; + if let Some(user_id) = self.client_config.resolve_user_id() { + insert_header(&mut headers, "x-lancedb-user-id", &user_id)?; + } + Ok(headers) + } +} + +struct QueryRegistry { + queries: StdMutex>>, +} + +impl QueryRegistry { + fn new() -> Self { + Self { + queries: StdMutex::new(HashMap::new()), + } + } + + fn insert(&self, id: Uuid, query: Arc) { + self.remove_expired(); + self.queries.lock().unwrap().insert(id, query); + } + + fn get(&self, id: Uuid) -> Option> { + self.remove_expired(); + let query = self.queries.lock().unwrap().get(&id).cloned(); + if let Some(query) = &query { + query.touch(); + } + query + } + + fn remove_expired(&self) { + self.queries + .lock() + .unwrap() + .retain(|_, query| !query.registry_expired(Arc::strong_count(query) == 1)); + } +} + +struct RemoteQuery { + id: Uuid, + client: Arc, + default_namespace_path: Vec, + state: Mutex, + poll_gate: Mutex<()>, + cancel_gate: Mutex<()>, + state_changed: Notify, + cancelled: Notify, + expires_at: StdMutex>>, + terminal_at: OnceLock, + last_accessed: StdMutex, + lifecycle: StdMutex, + cancel_request_uncertain: Arc, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QueryLifecycle { + Running, + Ready, + Cancelling, + Completed, + Cancelled, +} + +impl RemoteQuery { + fn new( + id: Uuid, + client: Arc, + default_namespace_path: Vec, + poll_info: PollInfo, + ) -> Result { + let expires_at = query_expiration(&poll_info)?; + let terminal_at = OnceLock::new(); + let lifecycle = if poll_info.flight_descriptor.is_none() { + let _ = terminal_at.set(Instant::now()); + QueryLifecycle::Ready + } else { + QueryLifecycle::Running + }; + Ok(Self { + id, + client, + default_namespace_path, + state: Mutex::new(poll_info), + poll_gate: Mutex::new(()), + cancel_gate: Mutex::new(()), + state_changed: Notify::new(), + cancelled: Notify::new(), + expires_at: StdMutex::new(expires_at), + terminal_at, + last_accessed: StdMutex::new(Instant::now()), + lifecycle: StdMutex::new(lifecycle), + cancel_request_uncertain: Arc::new(AtomicBool::new(false)), + }) + } + + fn registry_expired(&self, abandoned: bool) -> bool { + if let Some(finished) = self.terminal_at.get() { + return finished.elapsed() >= TERMINAL_QUERY_RETENTION; + } + self.expires_at + .lock() + .unwrap() + .is_some_and(|expires_at| expires_at <= chrono::Utc::now()) + || (abandoned + && self.last_accessed.lock().unwrap().elapsed() >= ABANDONED_QUERY_RETENTION) + } + + fn mark_terminal(&self) { + let _ = self.terminal_at.set(Instant::now()); + } + + fn mark_ready(&self) { + let mut lifecycle = self.lifecycle.lock().unwrap(); + if *lifecycle == QueryLifecycle::Running { + *lifecycle = QueryLifecycle::Ready; + } + drop(lifecycle); + self.mark_terminal(); + } + + fn mark_cancelled(&self) -> bool { + self.cancel_request_uncertain.store(false, Ordering::SeqCst); + let mut lifecycle = self.lifecycle.lock().unwrap(); + if matches!( + *lifecycle, + QueryLifecycle::Cancelled | QueryLifecycle::Completed + ) { + return false; + } + *lifecycle = QueryLifecycle::Cancelled; + drop(lifecycle); + self.mark_terminal(); + self.cancelled.notify_waiters(); + self.state_changed.notify_waiters(); + true + } + + fn mark_cancelling(&self) { + self.cancel_request_uncertain.store(false, Ordering::SeqCst); + let mut lifecycle = self.lifecycle.lock().unwrap(); + if matches!(*lifecycle, QueryLifecycle::Running | QueryLifecycle::Ready) { + *lifecycle = QueryLifecycle::Cancelling; + drop(lifecycle); + self.cancelled.notify_waiters(); + self.state_changed.notify_waiters(); + } + } + + async fn restore_after_rejected_cancellation(&self) { + self.cancel_request_uncertain.store(false, Ordering::SeqCst); + let running = self.state.lock().await.flight_descriptor.is_some(); + let mut lifecycle = self.lifecycle.lock().unwrap(); + if *lifecycle == QueryLifecycle::Cancelling { + *lifecycle = if running { + QueryLifecycle::Running + } else { + QueryLifecycle::Ready + }; + drop(lifecycle); + self.state_changed.notify_waiters(); + } + } + + fn mark_result_completed(&self) -> Result<()> { + let mut lifecycle = self.lifecycle.lock().unwrap(); + if matches!( + *lifecycle, + QueryLifecycle::Cancelling | QueryLifecycle::Cancelled + ) { + return Err(self.cancelled_error()); + } + *lifecycle = QueryLifecycle::Completed; + Ok(()) + } + + fn lifecycle(&self) -> QueryLifecycle { + *self.lifecycle.lock().unwrap() + } + + fn is_cancellation_requested(&self) -> bool { + matches!( + self.lifecycle(), + QueryLifecycle::Cancelling | QueryLifecycle::Cancelled + ) + } + + fn cancelled_error(&self) -> Error { + Error::JobCancelled { + job_id: Some(self.id.to_string()), + } + } + + async fn wait_for_cancellation(&self) { + loop { + let cancelled = self.cancelled.notified(); + if self.is_cancellation_requested() { + return; + } + cancelled.await; + } + } + + fn touch(&self) { + *self.last_accessed.lock().unwrap() = Instant::now(); + } + + async fn poll_next_state(&self, descriptor: FlightDescriptor) -> Result { + self.touch(); + if self.is_cancellation_requested() { + return Err(self.cancelled_error()); + } + let _poll_guard = tokio::select! { + biased; + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + poll_guard = self.poll_gate.lock() => poll_guard, + }; + let latest = self.state.lock().await.clone(); + if latest.flight_descriptor.as_ref() != Some(&descriptor) { + return Ok(latest); + } + let updated = tokio::select! { + biased; + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + result = self.client.poll_continuation( + descriptor.clone(), + &self.default_namespace_path, + ) => match result { + Err(_) if self.is_cancellation_requested() => { + return Err(self.cancelled_error()); + } + result => result?, + }, + }; + self.update_state(&descriptor, updated).await + } + + async fn prepare_result(self: &Arc) -> Result { + loop { + if self.is_cancellation_requested() { + return Err(self.cancelled_error()); + } + let state = self.state.lock().await.clone(); + if let Some(info) = state.info { + if let Some(endpoint) = info.endpoint.first().cloned() { + let mut endpoint_stream = tokio::select! { + biased; + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + result = self.client.open_result_endpoint( + endpoint, + &self.default_namespace_path, + ) => result?, + }; + let buffered_batch = tokio::select! { + biased; + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + result = endpoint_stream.next_batch() => result?, + }; + let schema = buffered_batch + .as_ref() + .map(RecordBatch::schema) + .or_else(|| endpoint_stream.stream.schema().cloned()) + .ok_or_else(|| Error::Runtime { + message: "SQL result endpoint did not include a schema".to_string(), + })?; + return Ok(PreparedSqlResult { + schema, + next_endpoint: 1, + endpoint_stream: buffered_batch.is_some().then_some(endpoint_stream), + buffered_batch, + }); + } + if state.flight_descriptor.is_none() { + let schema = if info.schema.is_empty() { + Arc::new(Schema::empty()) + } else { + let request_id = uuid::Uuid::new_v4().to_string(); + Arc::new( + info.try_decode_schema() + .map_err(|err| sql_error(&request_id, err))?, + ) + }; + return Ok(PreparedSqlResult { + schema, + next_endpoint: 0, + endpoint_stream: None, + buffered_batch: None, + }); + } + } else if state.flight_descriptor.is_none() { + return Err(Error::Runtime { + message: "Completed SQL query did not include result information".to_string(), + }); + } + let descriptor = state.flight_descriptor.ok_or_else(|| Error::Runtime { + message: "Completed SQL query did not include result information".to_string(), + })?; + self.poll_next_state(descriptor).await?; + } + } + + async fn run_result_stream( + self: Arc, + mut prepared: PreparedSqlResult, + sender: mpsc::Sender>, + ) -> Result<()> { + if let Some(batch) = prepared.buffered_batch.take() + && !self + .send_result_batch(&sender, &prepared.schema, batch) + .await? + { + return Ok(()); + } + loop { + if self.is_cancellation_requested() { + return Err(self.cancelled_error()); + } + if let Some(endpoint_stream) = prepared.endpoint_stream.as_mut() { + let batch = tokio::select! { + biased; + _ = sender.closed() => return Ok(()), + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + result = endpoint_stream.next_batch() => result?, + }; + if let Some(batch) = batch { + if !self + .send_result_batch(&sender, &prepared.schema, batch) + .await? + { + return Ok(()); + } + } else { + prepared.endpoint_stream = None; + } + continue; + } + + let state = self.state.lock().await.clone(); + let endpoints = state + .info + .as_ref() + .map(|info| info.endpoint.as_slice()) + .unwrap_or_default(); + if prepared.next_endpoint > endpoints.len() { + return Err(Error::Runtime { + message: "SQL service removed a previously advertised result endpoint" + .to_string(), + }); + } + if let Some(endpoint) = endpoints.get(prepared.next_endpoint).cloned() { + prepared.next_endpoint += 1; + prepared.endpoint_stream = Some(tokio::select! { + biased; + _ = sender.closed() => return Ok(()), + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + result = self.client.open_result_endpoint( + endpoint, + &self.default_namespace_path, + ) => result?, + }); + continue; + } + if let Some(descriptor) = state.flight_descriptor { + tokio::select! { + biased; + _ = sender.closed() => return Ok(()), + _ = self.wait_for_cancellation() => return Err(self.cancelled_error()), + result = self.poll_next_state(descriptor) => result?, + }; + continue; + } + self.mark_result_completed()?; + return Ok(()); + } + } + + async fn send_result_batch( + &self, + sender: &mpsc::Sender>, + schema: &SchemaRef, + batch: RecordBatch, + ) -> Result { + if batch.schema().as_ref() != schema.as_ref() { + return Err(Error::Runtime { + message: "SQL result endpoint returned a different schema".to_string(), + }); + } + tokio::select! { + biased; + _ = self.wait_for_cancellation() => Err(self.cancelled_error()), + result = sender.send(Ok(batch)) => Ok(result.is_ok()), + } + } + + async fn update_state( + &self, + descriptor: &FlightDescriptor, + updated: PollInfo, + ) -> Result { + self.touch(); + let expires_at = query_expiration(&updated)?; + let mut state = self.state.lock().await; + if state.flight_descriptor.as_ref() == Some(descriptor) { + if updated.flight_descriptor.is_none() { + self.mark_ready(); + } + *self.expires_at.lock().unwrap() = expires_at; + *state = updated; + self.state_changed.notify_waiters(); + } + Ok(state.clone()) + } +} + +impl RemoteQuery { + async fn describe(&self) -> Result { + let timeout = self.client.overall_timeout()?; + with_overall_timeout(timeout, "SQL query description", self.describe_inner()).await + } + + async fn describe_inner(&self) -> Result { + self.touch(); + if self.is_cancellation_requested() { + let state = self.state.lock().await.clone(); + return query_description(self.id, &state, self.lifecycle()); + } + let state = self.state.lock().await.clone(); + let state = if let Some(descriptor) = state.flight_descriptor.clone() { + let poll_guard = tokio::select! { + biased; + _ = self.wait_for_cancellation() => return query_description( + self.id, + &state, + self.lifecycle(), + ), + poll_guard = tokio::time::timeout( + STATUS_POLL_TIMEOUT, + self.poll_gate.lock(), + ) => poll_guard, + }; + let Ok(_poll_guard) = poll_guard else { + return query_description(self.id, &state, self.lifecycle()); + }; + let latest = self.state.lock().await.clone(); + if latest.flight_descriptor.as_ref() != Some(&descriptor) { + latest + } else { + let updated = tokio::select! { + biased; + _ = self.wait_for_cancellation() => return query_description( + self.id, + &latest, + self.lifecycle(), + ), + result = self.client.poll_status( + descriptor.clone(), + &self.default_namespace_path, + ) => match result { + Err(_) if self.is_cancellation_requested() => return query_description( + self.id, + &latest, + self.lifecycle(), + ), + result => result?, + }, + }; + if let Some(updated) = updated { + self.update_state(&descriptor, updated).await? + } else { + latest + } + } + } else { + state + }; + query_description(self.id, &state, self.lifecycle()) + } + + async fn cancel(&self) -> Result<()> { + let timeout = self.client.overall_timeout()?; + with_overall_timeout(timeout, "SQL query cancellation", self.cancel_inner()).await + } + + async fn cancel_inner(&self) -> Result<()> { + self.touch(); + let _cancel_guard = self.cancel_gate.lock().await; + if matches!( + self.lifecycle(), + QueryLifecycle::Cancelled | QueryLifecycle::Completed + ) { + return Ok(()); + } + loop { + let notified = self.state_changed.notified(); + let state = self.state.lock().await.clone(); + if let Some(info) = state.info { + let previously_uncertain = self.cancel_request_uncertain.load(Ordering::SeqCst); + let outcome = match self + .client + .cancel( + info, + &self.default_namespace_path, + self.cancel_request_uncertain.clone(), + ) + .await + { + Ok(outcome) => outcome, + Err(_) + if matches!( + self.lifecycle(), + QueryLifecycle::Cancelled | QueryLifecycle::Completed + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + }; + if matches!( + self.lifecycle(), + QueryLifecycle::Cancelled | QueryLifecycle::Completed + ) { + return Ok(()); + } + let status = match outcome { + CancelOutcome::Status(status) => status, + CancelOutcome::NotFound(_) + if self.lifecycle() == QueryLifecycle::Cancelling => + { + self.mark_cancelled(); + return Ok(()); + } + CancelOutcome::NotFound(request_id) => { + let message = if previously_uncertain { + "SQL query cancellation outcome is unknown because a prior request may have reached the service and the target was not found on retry" + } else { + "SQL query cancellation target was not found" + }; + return Err(sql_error(&request_id, message)); + } + }; + return match status { + CancelStatus::Cancelled => { + self.mark_cancelled(); + Ok(()) + } + CancelStatus::Cancelling => { + self.mark_cancelling(); + Ok(()) + } + CancelStatus::NotCancellable => { + self.restore_after_rejected_cancellation().await; + Err(Error::NotSupported { + message: "The SQL query is not cancellable".to_string(), + }) + } + CancelStatus::Unspecified => Err(Error::Runtime { + message: "The SQL service returned an unspecified cancellation status" + .to_string(), + }), + }; + } + let Some(descriptor) = state.flight_descriptor else { + return Ok(()); + }; + + tokio::select! { + poll_guard = self.poll_gate.lock() => { + let _poll_guard = poll_guard; + if self.state.lock().await.flight_descriptor.as_ref() != Some(&descriptor) { + continue; + } + let updated = self.client.poll_continuation( + descriptor.clone(), + &self.default_namespace_path, + ).await?; + self.update_state(&descriptor, updated).await?; + } + _ = notified => {} + } + } + } +} + +struct RemoteQueryHandle { + query: Arc, + result_started: AtomicBool, +} + +impl RemoteQueryHandle { + fn new(query: Arc) -> Self { + Self { + query, + result_started: AtomicBool::new(false), + } + } +} + +#[async_trait::async_trait] +impl QueryHandle for RemoteQueryHandle { + fn id(&self) -> Uuid { + self.query.touch(); + self.query.id + } + + async fn describe(&self) -> Result { + self.query.describe().await + } + + async fn reader(&self) -> Result { + let timeout = self.query.client.overall_timeout()?; + if self + .result_started + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return Err(Error::Runtime { + message: "SQL query results can only be consumed once".to_string(), + }); + } + let result_start = ResultStartGuard::new(&self.result_started); + let started = Instant::now(); + let prepared = + with_overall_timeout(timeout, "SQL query result", self.query.prepare_result()).await?; + let remaining_timeout = timeout.map(|timeout| timeout.saturating_sub(started.elapsed())); + let schema = prepared.schema.clone(); + let (sender, receiver) = mpsc::channel(2); + let error_sender = sender.clone(); + let query = self.query.clone(); + tokio::spawn(async move { + let result = with_overall_timeout( + remaining_timeout, + "SQL query result", + query.run_result_stream(prepared, sender), + ) + .await; + if let Err(error) = result { + let _ = error_sender.send(Err(error)).await; + } + }); + let stream = futures::stream::unfold(receiver, |mut receiver| async move { + receiver.recv().await.map(|item| (item, receiver)) + }); + result_start.commit(); + Ok(Box::pin(SimpleRecordBatchStream::new(stream, schema))) + } + + async fn cancel(&self) -> Result<()> { + self.query.cancel().await + } +} + +fn query_description( + id: Uuid, + poll_info: &PollInfo, + lifecycle: QueryLifecycle, +) -> Result { + let expires_at = query_expiration(poll_info)?; + Ok(QueryDescription { + id, + status: match lifecycle { + QueryLifecycle::Cancelling => QueryStatus::Cancelling, + QueryLifecycle::Cancelled => QueryStatus::Cancelled, + QueryLifecycle::Running if poll_info.flight_descriptor.is_some() => { + QueryStatus::Running + } + QueryLifecycle::Running | QueryLifecycle::Ready | QueryLifecycle::Completed => { + QueryStatus::Finished + } + }, + progress: poll_info.progress, + expires_at, + }) +} + +fn query_expiration(poll_info: &PollInfo) -> Result>> { + poll_info + .expiration_time + .as_ref() + .map(|timestamp| { + u32::try_from(timestamp.nanos) + .ok() + .and_then(|nanos| chrono::DateTime::from_timestamp(timestamp.seconds, nanos)) + .ok_or_else(|| Error::Runtime { + message: "SQL service returned an invalid query expiration time".to_string(), + }) + }) + .transpose() +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SqlTarget { + uri: String, + tls: bool, +} + +fn resolve_sql_host_override( + host_override: Option<&str>, + sql_host_override: Option<&str>, +) -> Result { + if let Some(uri) = sql_host_override { + return normalize_sql_host_override(uri); + } + let host_override = host_override.ok_or_else(|| Error::InvalidInput { + message: "sql_host_override is required when the SQL service endpoint cannot be derived from host_override".to_string(), + })?; + let parsed = url::Url::parse(host_override).map_err(|err| Error::InvalidInput { + message: format!("Invalid host_override: {err}"), + })?; + if parsed.scheme() != "http" { + return Err(Error::InvalidInput { + message: "sql_host_override is required for TLS or non-HTTP host overrides".to_string(), + }); + } + validate_endpoint_url(&parsed, "host_override")?; + let port = match parsed.port().or(explicit_port(host_override)) { + Some(u16::MAX) => { + return Err(Error::InvalidInput { + message: "sql_host_override is required when host_override uses port 65535" + .to_string(), + }); + } + Some(port) => port + 1, + None => DEFAULT_SQL_PORT, + }; + Ok(SqlTarget { + uri: endpoint_uri("http", parsed.host_str().unwrap(), port), + tls: false, + }) +} + +fn normalize_sql_host_override(uri: &str) -> Result { + let parsed = url::Url::parse(uri).map_err(|err| Error::InvalidInput { + message: format!("Invalid sql_host_override: {err}"), + })?; + validate_endpoint_url(&parsed, "sql_host_override")?; + let tls = match parsed.scheme().to_ascii_lowercase().as_str() { + "grpc" | "grpc+tcp" | "http" => false, + "grpc+tls" | "grpcs" | "https" => true, + _ => { + return Err(Error::InvalidInput { + message: + "sql_host_override must use grpc, grpc+tcp, grpc+tls, grpcs, http, or https" + .to_string(), + }); + } + }; + let port = parsed.port().or(explicit_port(uri)).unwrap_or(if tls { + DEFAULT_SQL_TLS_PORT + } else { + DEFAULT_SQL_PORT + }); + if port == 0 { + return Err(Error::InvalidInput { + message: "sql_host_override port must be greater than zero".to_string(), + }); + } + Ok(SqlTarget { + uri: endpoint_uri( + if tls { "https" } else { "http" }, + parsed.host_str().unwrap(), + port, + ), + tls, + }) +} + +fn explicit_port(uri: &str) -> Option { + let authority = uri.split_once("://")?.1.split(['/', '?', '#']).next()?; + let suffix = if authority.starts_with('[') { + authority.split_once(']')?.1.strip_prefix(':')? + } else { + authority.rsplit_once(':')?.1 + }; + suffix.parse().ok() +} + +fn validate_endpoint_url(parsed: &url::Url, name: &str) -> Result<()> { + if parsed.host_str().is_none() { + return Err(Error::InvalidInput { + message: format!("{name} must include a hostname"), + }); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(Error::InvalidInput { + message: format!("{name} must not include user information"), + }); + } + if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some() + { + return Err(Error::InvalidInput { + message: format!("{name} must not include a path, query, or fragment"), + }); + } + Ok(()) +} + +fn endpoint_uri(scheme: &str, host: &str, port: u16) -> String { + if host.contains(':') { + let host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + format!("{scheme}://[{host}]:{port}") + } else { + format!("{scheme}://{host}:{port}") + } +} + +async fn connect_channel( + target: &SqlTarget, + config: &ClientConfig, + request_id: &str, +) -> Result { + let connect_timeout = resolve_timeout( + config.timeout_config.connect_timeout, + "LANCE_CLIENT_CONNECT_TIMEOUT", + Some(DEFAULT_CONNECT_TIMEOUT), + )? + .unwrap(); + let mut endpoint = Endpoint::from_shared(target.uri.clone()) + .map_err(|err| sql_error(request_id, err))? + .connect_timeout(connect_timeout); + if target.tls { + endpoint = endpoint + .tls_config(tls_config(config.tls_config.as_ref())?) + .map_err(|err| sql_error(request_id, err))?; + } + tokio::time::timeout(connect_timeout, endpoint.connect()) + .await + .map_err(|_| sql_error(request_id, "SQL connection timed out"))? + .map_err(|err| sql_error(request_id, err)) +} + +fn tls_config(config: Option<&TlsConfig>) -> Result { + let mut tls = ClientTlsConfig::new().with_enabled_roots(); + if let Some(config) = config { + if !config.assert_hostname { + return Err(Error::InvalidInput { + message: "SQL cannot disable TLS hostname verification".to_string(), + }); + } + if let Some(path) = &config.ssl_ca_cert { + let pem = fs::read(path).map_err(|err| Error::InvalidInput { + message: format!("Failed to read SQL CA certificate {path}: {err}"), + })?; + tls = tls.ca_certificate(Certificate::from_pem(pem)); + } + match (&config.cert_file, &config.key_file) { + (Some(cert), Some(key)) => { + let cert_pem = fs::read(cert).map_err(|err| Error::InvalidInput { + message: format!("Failed to read SQL client certificate {cert}: {err}"), + })?; + let key_pem = fs::read(key).map_err(|err| Error::InvalidInput { + message: format!("Failed to read SQL client key {key}: {err}"), + })?; + tls = tls.identity(Identity::from_pem(cert_pem, key_pem)); + } + (None, None) => {} + _ => { + return Err(Error::InvalidInput { + message: "SQL mTLS requires both cert_file and key_file".to_string(), + }); + } + } + } + Ok(tls) +} + +fn client_with_headers( + client: FlightServiceClient, + headers: &HeaderMap, +) -> Result { + let mut client = FlightClient::new_from_inner(client); + for (key, value) in headers { + let value = value.to_str().map_err(|err| Error::InvalidInput { + message: format!("Invalid SQL metadata value for {key:?}: {err}"), + })?; + client + .add_header(key.as_str(), value) + .map_err(|err| Error::InvalidInput { + message: format!("Invalid SQL metadata header {key:?}: {err}"), + })?; + } + Ok(client) +} + +fn merge_headers(destination: &mut HeaderMap, source: &HashMap) -> Result<()> { + for (key, value) in source { + insert_header(destination, key, value)?; + } + Ok(()) +} + +fn insert_header(headers: &mut HeaderMap, key: &str, value: &str) -> Result<()> { + let key = HeaderName::from_bytes(key.as_bytes()).map_err(|err| Error::InvalidInput { + message: format!("Invalid SQL metadata key {key:?}: {err}"), + })?; + let value = HeaderValue::try_from(value).map_err(|err| Error::InvalidInput { + message: format!("Invalid SQL metadata value for {key:?}: {err}"), + })?; + headers.insert(key, value); + Ok(()) +} + +fn validate_namespace_path(path: &[String]) -> Result<()> { + for component in path { + if component.is_empty() + || !component.is_ascii() + || component.contains('$') + || component.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) + { + return Err(Error::InvalidInput { + message: "default_namespace_path components must be non-empty printable ASCII strings without '$'".to_string(), + }); + } + } + Ok(()) +} + +fn poll_retry_delay(config: &ResolvedRetryConfig, retry_count: u8) -> Duration { + let exponent = i32::from(retry_count.saturating_sub(1).min(16)); + let backoff = config.backoff_factor * 2.0_f32.powi(exponent); + let jitter = rand::random::() * config.backoff_jitter; + Duration::from_secs_f32((backoff + jitter).clamp(MIN_POLL_INTERVAL.as_secs_f32(), 60.0)) +} + +fn cancellation_status_is_ambiguous(code: tonic::Code) -> bool { + matches!( + code, + tonic::Code::Cancelled + | tonic::Code::Unknown + | tonic::Code::DeadlineExceeded + | tonic::Code::Internal + | tonic::Code::Unavailable + | tonic::Code::DataLoss + ) +} + +fn resolve_timeout( + configured: Option, + env_name: &str, + default: Option, +) -> Result> { + if configured.is_some() { + return Ok(configured); + } + match std::env::var(env_name) { + Ok(value) => value + .parse::() + .map(Duration::from_secs) + .map(Some) + .map_err(|_| Error::InvalidInput { + message: format!("Invalid value for {env_name} environment variable: {value:?}"), + }), + Err(_) => Ok(default), + } +} + +async fn with_overall_timeout( + timeout: Option, + operation: &str, + future: impl std::future::Future>, +) -> Result { + match timeout { + Some(timeout) => { + tokio::time::timeout(timeout, future) + .await + .map_err(|_| Error::Runtime { + message: format!("{operation} timed out"), + })? + } + None => future.await, + } +} + +fn sql_error(request_id: &str, error: impl std::fmt::Display) -> Error { + Error::Runtime { + message: format!("SQL error (request_id={request_id}): {error}"), + } +} + +#[cfg(test)] +#[path = "sql_test.rs"] +mod tests; diff --git a/rust/lancedb/src/remote/sql_test.rs b/rust/lancedb/src/remote/sql_test.rs new file mode 100644 index 000000000..d20ba495f --- /dev/null +++ b/rust/lancedb/src/remote/sql_test.rs @@ -0,0 +1,1100 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::sync::atomic::AtomicUsize; + +use arrow_array::builder::StringDictionaryBuilder; +use arrow_array::{Array, Int64Array, StringArray, types::Int32Type}; +use arrow_flight::encode::FlightDataEncoderBuilder; +use arrow_flight::flight_service_server::{FlightService, FlightServiceServer}; +use arrow_flight::sql::{Any, CommandStatementQuery}; +use arrow_flight::{ + Action, ActionType, CancelFlightInfoResult, Criteria, Empty, FlightData, FlightEndpoint, + FlightInfo, HandshakeRequest, HandshakeResponse, PollInfo, PutResult, SchemaResult, Ticket, +}; +use arrow_schema::{DataType, Field, Schema}; +use futures::stream::BoxStream; +use futures::{StreamExt, TryStreamExt}; +use tonic::{Request, Response, Status, Streaming}; + +use super::*; +use crate::database::Database; +use crate::remote::RemoteCatalogOptions; +use crate::remote::client::HeaderProvider; +use crate::remote::db::RemoteDatabase; + +#[derive(Debug, Default)] +struct DelayedHeaderProvider { + delay_next: AtomicBool, +} + +#[async_trait::async_trait] +impl HeaderProvider for DelayedHeaderProvider { + async fn get_headers(&self) -> Result> { + if self.delay_next.swap(false, Ordering::SeqCst) { + tokio::time::sleep(Duration::from_millis(1_100)).await; + } + Ok(HashMap::new()) + } +} + +fn assert_overall_timeout(result: Result, operation: &str) { + match result { + Err(Error::Runtime { message }) => { + assert_eq!(message, format!("SQL query {operation} timed out")); + } + _ => panic!("SQL query {operation} did not honor the overall timeout"), + } +} + +async fn collect_result(query: &Query) -> Result> { + query.reader().await?.try_collect().await +} + +#[derive(Debug)] +struct CapturedHeaders { + database: String, + namespace_path: String, + request_id: String, + api_key: String, + database_prefix: String, +} + +#[derive(Clone)] +struct TestSqlService { + query_count: Arc, + do_get_count: Arc, + cancel_count: Arc, + cancel_denied_count: Arc, + cancel_timeout_count: Arc, + cancel_unspecified_count: Arc, + cancelling_response_count: Arc, + incremental_finished: Arc, + first_continuation_count: Arc, + transient_poll_failures: Arc, + headers: Arc>>, + result: RecordBatch, + large_result: RecordBatch, + dictionary_result: RecordBatch, +} + +impl Default for TestSqlService { + fn default() -> Self { + let schema = Arc::new(Schema::new(vec![Field::new( + "value", + DataType::Int64, + false, + )])); + let result = + RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(vec![42_i64]))]).unwrap(); + let large_schema = Arc::new(Schema::new(vec![Field::new( + "value", + DataType::Utf8, + false, + )])); + let large_result = RecordBatch::try_new( + large_schema, + vec![Arc::new(StringArray::from(vec![ + "x".repeat(5 * 1024 * 1024), + ]))], + ) + .unwrap(); + let mut dictionary_builder = StringDictionaryBuilder::::new(); + dictionary_builder.append("dictionary value").unwrap(); + let dictionary = dictionary_builder.finish(); + let dictionary_schema = Arc::new(Schema::new(vec![Field::new( + "value", + dictionary.data_type().clone(), + false, + )])); + let dictionary_result = + RecordBatch::try_new(dictionary_schema, vec![Arc::new(dictionary)]).unwrap(); + Self { + query_count: Arc::new(AtomicUsize::new(0)), + do_get_count: Arc::new(AtomicUsize::new(0)), + cancel_count: Arc::new(AtomicUsize::new(0)), + cancel_denied_count: Arc::new(AtomicUsize::new(0)), + cancel_timeout_count: Arc::new(AtomicUsize::new(0)), + cancel_unspecified_count: Arc::new(AtomicUsize::new(0)), + cancelling_response_count: Arc::new(AtomicUsize::new(0)), + incremental_finished: Arc::new(AtomicBool::new(false)), + first_continuation_count: Arc::new(AtomicUsize::new(0)), + transient_poll_failures: Arc::new(AtomicUsize::new(0)), + headers: Arc::new(std::sync::Mutex::new(Vec::new())), + result, + large_result, + dictionary_result, + } + } +} + +#[tonic::async_trait] +impl FlightService for TestSqlService { + type HandshakeStream = BoxStream<'static, std::result::Result>; + type ListFlightsStream = BoxStream<'static, std::result::Result>; + type DoGetStream = BoxStream<'static, std::result::Result>; + type DoPutStream = BoxStream<'static, std::result::Result>; + type DoActionStream = BoxStream<'static, std::result::Result>; + type ListActionsStream = BoxStream<'static, std::result::Result>; + type DoExchangeStream = BoxStream<'static, std::result::Result>; + + async fn handshake( + &self, + _request: Request>, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("handshake")) + } + + async fn list_flights( + &self, + _request: Request, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("list_flights")) + } + + async fn get_flight_info( + &self, + _request: Request, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("get_flight_info")) + } + + async fn poll_flight_info( + &self, + request: Request, + ) -> std::result::Result, Status> { + let metadata = request.metadata(); + let header = |name| { + metadata + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap() + .to_string() + }; + self.headers.lock().unwrap().push(CapturedHeaders { + database: header("database"), + namespace_path: header("namespace-path"), + request_id: header("x-request-id"), + api_key: header("x-api-key"), + database_prefix: metadata + .get("x-lancedb-database-prefix") + .map(|value| value.to_str().unwrap().to_string()) + .unwrap_or_default(), + }); + + let command = Any::decode(request.get_ref().cmd.as_ref()) + .ok() + .and_then(|any| any.unpack::().ok().flatten()); + let (query, stage) = if let Some(command) = command { + self.query_count.fetch_add(1, Ordering::SeqCst); + (command.query, 0_u8) + } else { + let continuation = std::str::from_utf8(request.get_ref().cmd.as_ref()) + .map_err(|_| Status::invalid_argument("invalid continuation"))?; + let mut parts = continuation.splitn(3, ':'); + if parts.next() != Some("poll") { + return Err(Status::invalid_argument("invalid continuation")); + } + let stage = parts + .next() + .and_then(|stage| stage.parse().ok()) + .ok_or_else(|| Status::invalid_argument("invalid continuation"))?; + if stage == 1 { + self.first_continuation_count.fetch_add(1, Ordering::SeqCst); + } + let query = parts + .next() + .ok_or_else(|| Status::invalid_argument("invalid continuation"))?; + (query.to_string(), stage) + }; + if (query == "SELECT slow" || query == "SELECT cancelling") && stage > 0 { + tokio::time::sleep(Duration::from_millis(250)).await; + } + if query == "SELECT no info" && stage == 1 { + tokio::time::sleep(Duration::from_millis(100)).await; + } + if query == "SELECT incremental" && stage == 1 { + tokio::time::sleep(Duration::from_millis(250)).await; + self.incremental_finished.store(true, Ordering::SeqCst); + } + if stage == 1 + && (query == "SELECT fail" + || (query == "SELECT retry" + && self.transient_poll_failures.fetch_add(1, Ordering::SeqCst) == 0)) + { + return Err(Status::unavailable("transient polling failure")); + } + let complete = if query == "SELECT no info" { + stage >= 2 + } else { + stage >= 1 + }; + + let first_ticket = if query == "SELECT incremental" { + format!("{query}:first") + } else { + query.clone() + }; + let mut info = FlightInfo::new().with_endpoint( + FlightEndpoint::new() + .with_ticket(Ticket::new(first_ticket)) + .with_location("grpc://127.0.0.1:1"), + ); + if query == "SELECT incremental" && stage > 0 { + info = info.with_endpoint( + FlightEndpoint::new() + .with_ticket(Ticket::new(format!("{query}:second"))) + .with_location("grpc://127.0.0.1:1"), + ); + } + if query != "SELECT empty" { + let schema = if query == "SELECT large message" { + self.large_result.schema_ref() + } else if query == "SELECT dictionary" { + self.dictionary_result.schema_ref() + } else { + self.result.schema_ref() + }; + info = info.try_with_schema(schema).unwrap(); + } + Ok(Response::new(PollInfo { + info: (query != "SELECT no info" || stage > 0).then_some(info), + flight_descriptor: (!complete) + .then(|| FlightDescriptor::new_cmd(format!("poll:{}:{query}", stage + 1))), + progress: Some(if complete { 1.0 } else { 0.25 }), + expiration_time: None, + })) + } + + async fn get_schema( + &self, + _request: Request, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("get_schema")) + } + + async fn do_get( + &self, + request: Request, + ) -> std::result::Result::DoGetStream>, Status> { + self.do_get_count.fetch_add(1, Ordering::SeqCst); + let ticket = request.get_ref().ticket.as_ref(); + let empty = ticket == b"SELECT empty"; + let slow = ticket == b"SELECT slow get"; + let large = ticket == b"SELECT large message"; + let result = if large { + self.large_result.clone() + } else if ticket == b"SELECT dictionary" { + self.dictionary_result.clone() + } else { + self.result.clone() + }; + let schema = result.schema(); + let input = futures::stream::once(async move { + if slow { + tokio::time::sleep(Duration::from_millis(250)).await; + } + (!empty).then_some(Ok(result)) + }) + .filter_map(futures::future::ready); + let mut encoder = FlightDataEncoderBuilder::new().with_schema(schema); + if large { + encoder = encoder.with_max_flight_data_size(8 * 1024 * 1024); + } + let stream = encoder.build(input).map_err(Status::from); + Ok(Response::new(Box::pin(stream))) + } + + async fn do_put( + &self, + _request: Request>, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("do_put")) + } + + async fn do_action( + &self, + request: Request, + ) -> std::result::Result, Status> { + if request.get_ref().r#type != "CancelFlightInfo" { + return Err(Status::invalid_argument("unexpected action")); + } + self.cancel_count.fetch_add(1, Ordering::SeqCst); + let cancel_request = CancelFlightInfoRequest::decode(request.get_ref().body.clone()) + .map_err(|_| Status::invalid_argument("invalid cancellation request"))?; + let query = cancel_request + .info + .and_then(|info| info.endpoint.into_iter().next()) + .and_then(|endpoint| endpoint.ticket) + .and_then(|ticket| String::from_utf8(ticket.ticket.to_vec()).ok()) + .ok_or_else(|| Status::invalid_argument("cancellation request had no ticket"))?; + if query == "SELECT cancel race" { + tokio::time::sleep(Duration::from_millis(250)).await; + } + if query == "SELECT cancel timeout" { + if self.cancel_timeout_count.fetch_add(1, Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_millis(250)).await; + } else { + return Err(Status::not_found("query cancellation completed")); + } + } + if query == "SELECT cancel missing" { + return Err(Status::not_found("query was not found")); + } + if query == "SELECT cancel denied" { + if self.cancel_denied_count.fetch_add(1, Ordering::SeqCst) == 0 { + return Err(Status::permission_denied("cancellation is not allowed")); + } + return Err(Status::not_found("query was not found")); + } + if query == "SELECT cancel unspecified" + && self.cancel_unspecified_count.fetch_add(1, Ordering::SeqCst) > 0 + { + return Err(Status::not_found("query cancellation completed")); + } + let status = if query == "SELECT cancel unspecified" { + CancelStatus::Unspecified + } else if query == "SELECT cancelling" { + if self + .cancelling_response_count + .fetch_add(1, Ordering::SeqCst) + == 0 + { + CancelStatus::Cancelling + } else { + return Err(Status::not_found("query cancellation completed")); + } + } else if query == "SELECT cancel race" { + CancelStatus::NotCancellable + } else { + CancelStatus::Cancelled + }; + let response = arrow_flight::Result { + body: CancelFlightInfoResult::new(status).encode_to_vec().into(), + }; + Ok(Response::new(Box::pin(futures::stream::iter([Ok( + response, + )])))) + } + + async fn list_actions( + &self, + _request: Request, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("list_actions")) + } + + async fn do_exchange( + &self, + _request: Request>, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("do_exchange")) + } +} + +#[tokio::test] +async fn catalog_connections_use_explicit_sql_endpoint_and_database_scope() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let incoming = futures::stream::try_unfold(listener, |listener| async { + let (socket, _) = listener.accept().await?; + Ok::<_, std::io::Error>(Some((socket, listener))) + }); + let service = TestSqlService::default(); + let headers = service.headers.clone(); + let expected = service.result.clone(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn( + tonic::transport::Server::builder() + .add_service(FlightServiceServer::new(service)) + .serve_with_incoming_shutdown(incoming, async { + let _ = shutdown_rx.await; + }), + ); + let options = RemoteCatalogOptions { + api_key: Some("catalog-key".into()), + sql_host_override: Some(format!("grpc://{address}")), + ..Default::default() + }; + for name in ["analytics", "team/search"] { + let database = + RemoteDatabase::for_catalog("https://catalog.example", Some(name), &options).unwrap(); + let query = database + .execute_query_async("SELECT 42", &[]) + .await + .unwrap(); + assert_eq!( + collect_result(&query).await.unwrap(), + vec![expected.clone()] + ); + } + { + let headers = headers.lock().unwrap(); + assert!(headers.iter().any(|header| header.database == "analytics")); + assert!( + headers + .iter() + .any(|header| header.database == "team/search") + ); + for header in headers.iter() { + assert_eq!(header.api_key, "catalog-key"); + assert_eq!(header.namespace_path, "public"); + assert!(header.database_prefix.is_empty()); + } + } + shutdown_tx.send(()).unwrap(); + server.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn submits_polls_fetches_cancels_and_reuses_client() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + + let service = TestSqlService::default(); + let query_count = service.query_count.clone(); + let do_get_count = service.do_get_count.clone(); + let cancel_count = service.cancel_count.clone(); + let incremental_finished = service.incremental_finished.clone(); + let first_continuation_count = service.first_continuation_count.clone(); + let headers = service.headers.clone(); + let expected = service.result.clone(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn( + tonic::transport::Server::builder() + .add_service(FlightServiceServer::new(service)) + .serve_with_shutdown(address, async { + let _ = shutdown_rx.await; + }), + ); + let mut ready = false; + for _ in 0..100 { + if tokio::net::TcpStream::connect(address).await.is_ok() { + ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(ready, "SQL test server did not start"); + + let mut client_config = ClientConfig::default(); + client_config.retry_config.read_retries = Some(1); + client_config.retry_config.backoff_factor = Some(0.0); + client_config.retry_config.backoff_jitter = Some(0.0); + client_config + .extra_headers + .insert("x-static-secret".to_string(), "static-secret".to_string()); + let header_provider = Arc::new(DelayedHeaderProvider::default()); + client_config.header_provider = Some(header_provider.clone()); + let client = SqlClient::new( + "analytics".to_string(), + Some("tenant/production".to_string()), + "test-key".to_string(), + None, + Some(format!("grpc://{address}")), + client_config, + ); + assert_eq!(client.initialized_client_count().await, 0); + assert!(!format!("{client:?}").contains("test-key")); + assert!(!format!("{client:?}").contains("static-secret")); + + let mut timeout_client_config = ClientConfig::default(); + timeout_client_config.timeout_config.timeout = Some(Duration::from_millis(50)); + let timeout_header_provider = Arc::new(DelayedHeaderProvider::default()); + timeout_client_config.header_provider = Some(timeout_header_provider.clone()); + let timeout_client = SqlClient::new( + "analytics".to_string(), + Some("tenant/production".to_string()), + "test-key".to_string(), + None, + Some(format!("grpc://{address}")), + timeout_client_config, + ); + timeout_header_provider + .delay_next + .store(true, Ordering::SeqCst); + assert_overall_timeout( + timeout_client + .submit("SELECT overall timeout", &["public".to_string()]) + .await, + "submission", + ); + let timeout_query = timeout_client + .submit("SELECT overall timeout", &["public".to_string()]) + .await + .unwrap(); + timeout_header_provider + .delay_next + .store(true, Ordering::SeqCst); + assert_overall_timeout( + timeout_client.describe(timeout_query.id()).await, + "description", + ); + timeout_header_provider + .delay_next + .store(true, Ordering::SeqCst); + assert_overall_timeout(collect_result(&timeout_query).await, "result"); + timeout_header_provider + .delay_next + .store(true, Ordering::SeqCst); + assert_overall_timeout(timeout_query.cancel().await, "cancellation"); + timeout_query.cancel().await.unwrap(); + + let pre_dispatch_timeout = timeout_client + .submit("SELECT cancel missing", &["public".to_string()]) + .await + .unwrap(); + timeout_header_provider + .delay_next + .store(true, Ordering::SeqCst); + assert_overall_timeout(pre_dispatch_timeout.cancel().await, "cancellation"); + assert!(pre_dispatch_timeout.cancel().await.is_err()); + assert_ne!( + pre_dispatch_timeout.describe().await.unwrap().status, + QueryStatus::Cancelled + ); + + let rejected_cancel = timeout_client + .submit("SELECT cancel denied", &["public".to_string()]) + .await + .unwrap(); + assert!(rejected_cancel.cancel().await.is_err()); + assert!(rejected_cancel.cancel().await.is_err()); + assert_ne!( + rejected_cancel.describe().await.unwrap().status, + QueryStatus::Cancelled + ); + + let unspecified_cancel = timeout_client + .submit("SELECT cancel unspecified", &["public".to_string()]) + .await + .unwrap(); + assert!(unspecified_cancel.cancel().await.is_err()); + assert!(unspecified_cancel.cancel().await.is_err()); + assert_ne!( + unspecified_cancel.describe().await.unwrap().status, + QueryStatus::Cancelled + ); + assert_eq!( + collect_result(&unspecified_cancel).await.unwrap(), + vec![expected.clone()] + ); + + let uncertain_cancel = timeout_client + .submit("SELECT cancel timeout", &["public".to_string()]) + .await + .unwrap(); + assert_overall_timeout(uncertain_cancel.cancel().await, "cancellation"); + assert!(uncertain_cancel.cancel().await.is_err()); + assert_ne!( + uncertain_cancel.describe().await.unwrap().status, + QueryStatus::Cancelled + ); + assert_eq!( + collect_result(&uncertain_cancel).await.unwrap(), + vec![expected.clone()] + ); + + let first = client + .submit("SELECT 'super-secret'", &["public".to_string()]) + .await + .unwrap(); + assert_eq!(first.id().get_version_num(), 7); + assert!(!first.id().to_string().contains("super-secret")); + header_provider.delay_next.store(true, Ordering::SeqCst); + let describe_started = Instant::now(); + let first_description = client.describe(first.id()).await.unwrap(); + assert!(describe_started.elapsed() >= Duration::from_millis(1_100)); + assert_eq!(first_description.status, QueryStatus::Finished); + assert_eq!(first_description.progress, Some(1.0)); + let first_result = collect_result(&first).await.unwrap(); + assert!(first.reader().await.is_err()); + + let incremental = client + .submit("SELECT incremental", &["public".to_string()]) + .await + .unwrap(); + let mut incremental_result = incremental.reader().await.unwrap(); + let first_incremental_batch = + tokio::time::timeout(Duration::from_millis(100), incremental_result.try_next()) + .await + .expect("the first partial result must arrive before query completion") + .unwrap() + .unwrap(); + assert_eq!(first_incremental_batch, expected); + assert!(!incremental_finished.load(Ordering::SeqCst)); + let remaining_incremental_batches = incremental_result.try_collect::>().await.unwrap(); + assert_eq!(remaining_incremental_batches, vec![expected.clone()]); + assert!(incremental_finished.load(Ordering::SeqCst)); + + let interrupted_result = Arc::new( + client + .submit("SELECT no info", &["public".to_string()]) + .await + .unwrap(), + ); + let continuation_count_before = first_continuation_count.load(Ordering::SeqCst); + let interrupted_result_task = { + let interrupted_result = interrupted_result.clone(); + tokio::spawn(async move { interrupted_result.reader().await }) + }; + tokio::time::timeout(Duration::from_millis(100), async { + while first_continuation_count.load(Ordering::SeqCst) == continuation_count_before { + tokio::task::yield_now().await; + } + }) + .await + .expect("result preparation must start polling"); + interrupted_result_task.abort(); + assert!( + interrupted_result_task + .await + .is_err_and(|error| error.is_cancelled()) + ); + assert_eq!( + collect_result(&interrupted_result).await.unwrap(), + vec![expected.clone()], + "cancelling result preparation must release the one-shot result claim", + ); + + let dropped_reader = client + .submit("SELECT slow", &["public".to_string()]) + .await + .unwrap(); + let tracked_dropped_reader = client.queries.get(dropped_reader.id()).unwrap(); + let continuation_count_before = first_continuation_count.load(Ordering::SeqCst); + let dropped_result_stream = dropped_reader.reader().await.unwrap(); + tokio::time::timeout(Duration::from_millis(100), async { + while first_continuation_count.load(Ordering::SeqCst) == continuation_count_before { + tokio::task::yield_now().await; + } + }) + .await + .expect("the result producer must start continuation polling"); + assert!(Arc::strong_count(&tracked_dropped_reader) >= 4); + drop(dropped_result_stream); + tokio::time::timeout(Duration::from_millis(100), async { + while Arc::strong_count(&tracked_dropped_reader) != 3 { + tokio::task::yield_now().await; + } + }) + .await + .expect("dropping a result reader must stop its producer"); + + let staged = client + .submit("SELECT no info", &["public".to_string()]) + .await + .unwrap(); + let staged_running = client.describe(staged.id()).await.unwrap(); + assert_eq!(staged_running.status, QueryStatus::Running); + let staged_finished = client.describe(staged.id()).await.unwrap(); + assert_eq!(staged_finished.status, QueryStatus::Finished); + + let empty = client + .submit("SELECT empty", &["public".to_string()]) + .await + .unwrap(); + let empty_result = empty.reader().await.unwrap(); + assert_eq!(empty_result.schema(), expected.schema()); + let empty_result = empty_result.try_collect::>().await.unwrap(); + + let large = client + .submit("SELECT large message", &["public".to_string()]) + .await + .unwrap(); + let large_result = collect_result(&large).await.unwrap(); + assert_eq!(large_result.len(), 1); + assert_eq!(large_result[0].num_rows(), 1); + assert_eq!( + large_result[0] + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .value(0) + .len(), + 5 * 1024 * 1024, + ); + + let dictionary = client + .submit("SELECT dictionary", &["public".to_string()]) + .await + .unwrap(); + let dictionary_result = collect_result(&dictionary).await.unwrap(); + assert_eq!(dictionary_result.len(), 1); + assert_eq!( + dictionary_result[0].schema().field(0).data_type(), + &DataType::Utf8, + ); + assert_eq!( + dictionary_result[0] + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + "dictionary value", + ); + + let cancelled = client + .submit( + "SELECT cancelled", + &["events".to_string(), "raw".to_string()], + ) + .await + .unwrap(); + cancelled.cancel().await.unwrap(); + assert_eq!( + cancelled.describe().await.unwrap().status, + QueryStatus::Cancelled + ); + assert!(matches!( + cancelled.reader().await, + Err(Error::JobCancelled { .. }) + )); + + let slow = Arc::new( + client + .submit("SELECT slow", &["public".to_string()]) + .await + .unwrap(), + ); + let result_task = { + let slow = slow.clone(); + tokio::spawn(async move { collect_result(&slow).await }) + }; + tokio::time::sleep(Duration::from_millis(25)).await; + tokio::time::timeout(Duration::from_millis(150), slow.cancel()) + .await + .expect("cancellation must not wait for result polling") + .unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_millis(150), result_task) + .await + .expect("cancellation must wake result polling") + .unwrap(), + Err(Error::JobCancelled { .. }) + )); + let cancel_count_after_slow = cancel_count.load(Ordering::SeqCst); + slow.cancel().await.unwrap(); + assert_eq!( + cancel_count.load(Ordering::SeqCst), + cancel_count_after_slow, + "a confirmed cancellation must not be sent again", + ); + + let slow_get = Arc::new( + client + .submit("SELECT slow get", &["public".to_string()]) + .await + .unwrap(), + ); + let do_get_count_before_slow = do_get_count.load(Ordering::SeqCst); + let slow_get_result_task = { + let slow_get = slow_get.clone(); + tokio::spawn(async move { collect_result(&slow_get).await }) + }; + while do_get_count.load(Ordering::SeqCst) == do_get_count_before_slow { + tokio::task::yield_now().await; + } + slow_get.cancel().await.unwrap(); + assert_eq!( + slow_get.describe().await.unwrap().status, + QueryStatus::Cancelled + ); + assert!(matches!( + tokio::time::timeout(Duration::from_millis(150), slow_get_result_task) + .await + .expect("cancellation must wake result fetching") + .unwrap(), + Err(Error::JobCancelled { .. }) + )); + assert!(slow_get.reader().await.is_err()); + + let restored = Arc::new( + RemoteQuery::new( + Uuid::now_v7(), + client.inner.clone(), + vec!["public".to_string()], + PollInfo { + flight_descriptor: Some(FlightDescriptor::new_cmd("restored")), + ..Default::default() + }, + ) + .unwrap(), + ); + let mut restored_waiter = { + let restored = restored.clone(); + tokio::spawn(async move { restored.wait_for_cancellation().await }) + }; + tokio::task::yield_now().await; + restored.mark_cancelling(); + restored.restore_after_rejected_cancellation().await; + assert_eq!(restored.lifecycle(), QueryLifecycle::Running); + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut restored_waiter) + .await + .is_err(), + "a stale cancellation notification must not complete the waiter", + ); + restored.mark_cancelling(); + tokio::time::timeout(Duration::from_millis(100), restored_waiter) + .await + .expect("a current cancellation must complete the waiter") + .unwrap(); + + let cancelling = Arc::new( + client + .submit("SELECT cancelling", &["public".to_string()]) + .await + .unwrap(), + ); + let cancelling_result_task = { + let cancelling = cancelling.clone(); + tokio::spawn(async move { collect_result(&cancelling).await }) + }; + tokio::time::sleep(Duration::from_millis(25)).await; + cancelling.cancel().await.unwrap(); + assert_eq!( + cancelling.describe().await.unwrap().status, + QueryStatus::Cancelling + ); + assert!(matches!( + tokio::time::timeout(Duration::from_millis(150), cancelling_result_task) + .await + .expect("an accepted cancellation must wake result polling") + .unwrap(), + Err(Error::JobCancelled { .. }) + )); + cancelling.cancel().await.unwrap(); + assert_eq!( + cancelling.describe().await.unwrap().status, + QueryStatus::Cancelled + ); + + let cancel_race = Arc::new( + client + .submit("SELECT cancel race", &["public".to_string()]) + .await + .unwrap(), + ); + let cancel_count_before_race = cancel_count.load(Ordering::SeqCst); + let cancel_race_task = { + let cancel_race = cancel_race.clone(); + tokio::spawn(async move { cancel_race.cancel().await }) + }; + while cancel_count.load(Ordering::SeqCst) == cancel_count_before_race { + tokio::task::yield_now().await; + } + let cancel_race_result = collect_result(&cancel_race).await.unwrap(); + tokio::time::timeout(Duration::from_millis(500), cancel_race_task) + .await + .expect("completed result must make in-flight cancellation a no-op") + .unwrap() + .unwrap(); + assert_eq!( + cancel_race.describe().await.unwrap().status, + QueryStatus::Finished + ); + assert_eq!(cancel_race_result, vec![expected.clone()]); + assert!(cancel_race.reader().await.is_err()); + + let no_info = Arc::new( + client + .submit("SELECT no info", &["public".to_string()]) + .await + .unwrap(), + ); + let continuation_count_before = first_continuation_count.load(Ordering::SeqCst); + let no_info_result_task = { + let no_info = no_info.clone(); + tokio::spawn(async move { collect_result(&no_info).await }) + }; + tokio::time::sleep(Duration::from_millis(10)).await; + tokio::time::timeout(Duration::from_secs(1), no_info.cancel()) + .await + .expect("cancellation should wait for cancellable query information") + .unwrap(); + assert!(matches!( + no_info_result_task.await.unwrap(), + Err(Error::JobCancelled { .. }) + )); + assert_eq!( + first_continuation_count.load(Ordering::SeqCst), + continuation_count_before + 1, + "result and cancel must share one continuation poll", + ); + + let retried = client + .submit("SELECT retry", &["public".to_string()]) + .await + .unwrap(); + assert_eq!( + collect_result(&retried).await.unwrap(), + vec![expected.clone()] + ); + + let failed = client + .submit("SELECT fail", &["public".to_string()]) + .await + .unwrap(); + assert!(collect_result(&failed).await.is_err()); + + let registry = QueryRegistry::new(); + for descriptor in ["active-one", "active-two"] { + let id = Uuid::now_v7(); + let query = Arc::new( + RemoteQuery::new( + id, + client.inner.clone(), + vec!["public".to_string()], + PollInfo { + flight_descriptor: Some(FlightDescriptor::new_cmd(descriptor)), + ..Default::default() + }, + ) + .unwrap(), + ); + registry.insert(id, query.clone()); + assert!(Arc::ptr_eq(®istry.get(id).unwrap(), &query)); + } + + let expired_id = Uuid::now_v7(); + let expired_query = Arc::new( + RemoteQuery::new( + expired_id, + client.inner.clone(), + vec!["public".to_string()], + PollInfo { + flight_descriptor: Some(FlightDescriptor::new_cmd("expired")), + expiration_time: Some(Default::default()), + ..Default::default() + }, + ) + .unwrap(), + ); + registry.insert(expired_id, expired_query); + assert!(registry.get(expired_id).is_none()); + + let stale_id = Uuid::now_v7(); + let stale_query = Arc::new( + RemoteQuery::new( + stale_id, + client.inner.clone(), + vec!["public".to_string()], + PollInfo { + flight_descriptor: Some(FlightDescriptor::new_cmd("stale")), + ..Default::default() + }, + ) + .unwrap(), + ); + *stale_query.last_accessed.lock().unwrap() = Instant::now() - ABANDONED_QUERY_RETENTION; + registry.insert(stale_id, stale_query.clone()); + drop(stale_query); + assert!(registry.get(stale_id).is_none()); + + assert_eq!(client.initialized_client_count().await, 1); + assert_eq!(query_count.load(Ordering::SeqCst), 21); + assert_eq!(do_get_count.load(Ordering::SeqCst), 17); + assert_eq!(cancel_count.load(Ordering::SeqCst), 15); + assert_eq!(first_result, vec![expected.clone()]); + assert!(empty_result.is_empty()); + assert!(client.describe(Uuid::nil()).await.is_err()); + { + let headers = headers.lock().unwrap(); + assert_eq!(headers[0].database, "analytics"); + assert_eq!(headers[0].namespace_path, "public"); + assert_eq!(headers[0].api_key, "test-key"); + assert_eq!(headers[0].database_prefix, "tenant/production"); + assert!( + headers + .iter() + .any(|header| header.namespace_path == "events$raw") + ); + assert!( + headers + .windows(2) + .all(|headers| headers[0].request_id != headers[1].request_id) + ); + } + let _ = shutdown_tx.send(()); + server.await.unwrap().unwrap(); +} + +#[test] +fn normalizes_supported_uris() { + assert_eq!( + normalize_sql_host_override("grpc://localhost").unwrap(), + SqlTarget { + uri: "http://localhost:10025".to_string(), + tls: false, + } + ); + assert_eq!( + normalize_sql_host_override("grpcs://example.com").unwrap(), + SqlTarget { + uri: "https://example.com:10026".to_string(), + tls: true, + } + ); + assert_eq!( + normalize_sql_host_override("grpc://[::1]:10025").unwrap(), + SqlTarget { + uri: "http://[::1]:10025".to_string(), + tls: false, + } + ); + assert_eq!( + normalize_sql_host_override("https://example.com:443").unwrap(), + SqlTarget { + uri: "https://example.com:443".to_string(), + tls: true, + } + ); +} + +#[test] +fn derives_plaintext_endpoint_from_host_override() { + assert_eq!( + resolve_sql_host_override(Some("http://localhost:10024"), None).unwrap(), + SqlTarget { + uri: "http://localhost:10025".to_string(), + tls: false, + } + ); + assert_eq!( + resolve_sql_host_override(Some("http://localhost:80"), None).unwrap(), + SqlTarget { + uri: "http://localhost:81".to_string(), + tls: false, + } + ); +} + +#[test] +fn rejects_unsafe_or_ambiguous_endpoints() { + assert!(normalize_sql_host_override("ftp://localhost").is_err()); + assert!(normalize_sql_host_override("grpc://user@localhost").is_err()); + assert!(normalize_sql_host_override("grpc://localhost/path").is_err()); + assert!(resolve_sql_host_override(Some("https://localhost"), None).is_err()); +} + +#[test] +fn validates_namespace_components() { + assert!(validate_namespace_path(&[]).is_ok()); + assert!(validate_namespace_path(&["events".into(), "raw".into()]).is_ok()); + assert!(validate_namespace_path(&["events$raw".into()]).is_err()); + assert!(validate_namespace_path(&["".into()]).is_err()); + assert!(validate_namespace_path(&["café".into()]).is_err()); +} + +#[test] +fn validates_metadata_with_header_map() { + let mut headers = HeaderMap::new(); + insert_header(&mut headers, "X-Custom-Header", "value").unwrap(); + assert_eq!(headers.get("x-custom-header").unwrap(), "value"); + assert!(insert_header(&mut headers, "bad header", "value").is_err()); + assert!(insert_header(&mut headers, "valid-header", "bad\nvalue").is_err()); +} diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index 57d2dc47d..75cf8cd00 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -17,6 +17,9 @@ use crate::index::IndexStatistics; use crate::index::scalar::FtsQuery; use crate::index::waiter::wait_for_index; use crate::job::Job; +use crate::materialized_view::{ + MaterializedViewDefinition, MaterializedViewInfo, RefreshMaterializedViewResult, ViewProjection, +}; use crate::query::{QueryFilter, QueryRequest, Select, VectorQueryRequest}; use crate::remote::job::RemoteJob; use crate::table::AddColumnsResult; @@ -40,8 +43,8 @@ use crate::table::{ use crate::table::{AnyQuery, Filter, Predicate, PreprocessingOutput, TableStatistics}; use crate::utils::background_cache::BackgroundCache; use crate::utils::{ - resolve_arrow_field_path, resolve_arrow_fts_field_path, supported_btree_data_type, - supported_vector_data_type, + MaxBatchLengthStream, TimeoutStream, public_fts_field_path_by_id, resolve_arrow_field_path, + resolve_arrow_fts_field_path, supported_btree_data_type, supported_vector_data_type, }; use crate::{DistanceType, Error}; use crate::{ @@ -72,7 +75,7 @@ use lance_datafusion::exec::{OneShotExec, execute_plan}; use reqwest::{RequestBuilder, Response}; use serde::{Deserialize, Serialize}; use serde_json::Number; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::Cursor; use std::pin::Pin; use std::sync::{Arc, Mutex}; @@ -250,10 +253,17 @@ struct FreshnessJob { inner: RemoteJob, freshness: Arc>, version: Arc>>, - track_refresh_result: bool, + tracked_result: TrackedJobResult, freshness_request: FreshnessHeaders, } +#[derive(Clone, Copy)] +enum TrackedJobResult { + None, + RefreshColumn, + MaterializedView, +} + #[async_trait] impl crate::job::JobHandle for FreshnessJob { fn id(&self) -> Option<&str> { @@ -264,26 +274,41 @@ impl crate::job::JobHandle for FreshnessJob { crate::job::JobHandle::status(&self.inner).await } + async fn describe(&self) -> Result { + crate::job::JobHandle::describe(&self.inner).await + } + + async fn events( + &self, + request: crate::job::JobEventsRequest, + ) -> Result> { + crate::job::JobHandle::events(&self.inner, request).await + } + async fn wait(&self) -> Result { let result = crate::job::JobHandle::wait(&self.inner).await?; let version = self.version.read().await; if version.is_none() { - let result_version = self - .track_refresh_result - .then(|| result.value()) - .flatten() - .and_then(|value| { + let result_version = match self.tracked_result { + TrackedJobResult::None => None, + TrackedJobResult::RefreshColumn => result.value().and_then(|value| { serde_json::from_value::(value.clone()) .ok() - }) - .map(|result| { - result - .published_version - .map_or(result.source_version, |version| { - version.max(result.source_version) + .map(|result| { + result + .published_version + .map_or(result.source_version, |version| { + version.max(result.source_version) + }) }) - }) - .filter(|version| *version != 0); + }), + TrackedJobResult::MaterializedView => result.value().and_then(|value| { + serde_json::from_value::(value.clone()) + .ok() + .map(|result| result.version) + }), + } + .filter(|version| *version != 0); if let Some(version) = result_version { self.freshness_request .observe_version(&self.freshness, version); @@ -527,6 +552,10 @@ impl RemoteTable { "column": canonical_column }); + if !index.replace { + body["replace"] = false.into(); + } + // Add name parameter if provided (for backwards compatibility, only include if Some) if let Some(ref name) = index.name { body["name"] = serde_json::Value::String(name.clone()); @@ -558,6 +587,10 @@ impl RemoteTable { Index::Bitmap(p) => ("BITMAP", Some(to_json(p)?)), Index::LabelList(p) => ("LABEL_LIST", Some(to_json(p)?)), Index::Fm(p) => ("FM", Some(to_json(p)?)), + Index::ZoneMap(p) => ("ZONEMAP", Some(to_json(p)?)), + Index::NGram(p) => ("NGRAM", Some(to_json(p)?)), + Index::BloomFilter(p) => ("BLOOM_FILTER", Some(to_json(p)?)), + Index::RTree(p) => ("RTREE", Some(to_json(p)?)), Index::FTS(p) => { let mut params = to_json(p)?; if p.get_document_granularity().is_list_element() { @@ -2013,7 +2046,19 @@ impl RemoteTable { } let results = futures::future::try_join_all(futures).await?; - Ok(results.into_iter().flatten().collect()) + let mut indices: Vec = results.into_iter().flatten().collect(); + let lance_schema = lance_core::datatypes::Schema::try_from(schema.as_ref())?; + for index in &mut indices { + if index.index_type == IndexType::FTS { + // The wire format uses physical paths for schema resolution. Match + // native tables by exposing list-transparent paths to callers. + for column in &mut index.columns { + let field_id = lance_schema.field_id(column)?; + *column = public_fts_field_path_by_id(&lance_schema, field_id)?; + } + } + } + Ok(indices) } } @@ -2022,6 +2067,9 @@ impl BaseTable for RemoteTable { fn as_any(&self) -> &dyn std::any::Any { self } + fn analyze_plan_is_remote(&self) -> bool { + true + } fn name(&self) -> &str { &self.name } @@ -2033,6 +2081,106 @@ impl BaseTable for RemoteTable { fn id(&self) -> &str { &self.identifier } + async fn materialized_view_info(&self) -> Result { + #[derive(Deserialize)] + struct Projection { + output_column: String, + expression: String, + } + + #[derive(Deserialize)] + struct DescribeMaterializedViewResponse { + source_table: String, + #[serde(default)] + source_namespace: Vec, + #[serde(default)] + projections: Vec, + #[serde(default)] + filter: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + inputs: Vec, + #[serde(default)] + incarnation: Option, + } + + let request = self.client.post(&format!( + "/v1/materialized_view/{}/describe", + self.identifier + )); + let (request_id, response) = self.send(request, true).await?; + let response = self.check_table_response(&request_id, response).await?; + let response: DescribeMaterializedViewResponse = + response.json().await.err_to_http(request_id)?; + Ok(MaterializedViewInfo { + definition: MaterializedViewDefinition { + source_table: response.source_table, + source_namespace: response.source_namespace, + projections: response + .projections + .into_iter() + .map(|projection| ViewProjection { + output: projection.output_column, + expression: projection.expression, + }) + .collect(), + filter: response.filter, + limit: response.limit, + inputs: response.inputs, + }, + incarnation: response.incarnation, + }) + } + + async fn refresh_materialized_view_async( + &self, + full: bool, + source_version: Option, + expected_incarnation: Option<&str>, + ) -> Result> { + self.check_mutable().await?; + let mut body = serde_json::json!({ "full": full }); + if let Some(source_version) = source_version { + body["source_version"] = source_version.into(); + } + if let Some(expected_incarnation) = expected_incarnation { + body["expected_incarnation"] = expected_incarnation.into(); + } + let request = self + .client + .post(&format!( + "/v1/materialized_view/{}/refresh", + self.identifier + )) + .json(&body); + let freshness_request = self.snapshot_freshness_headers(); + let (request_id, response) = self + .send_with_freshness(request, true, freshness_request) + .await?; + let response = self.check_table_response(&request_id, response).await?; + let status = response.status(); + let body = response.text().await.err_to_http(request_id.clone())?; + if status != StatusCode::ACCEPTED { + return Err(Error::Http { + source: "materialized-view refresh must return 202 Accepted".into(), + request_id, + status_code: Some(status), + }); + } + let job_id = extract_job_id(&body).ok_or_else(|| Error::Http { + source: "materialized-view refresh response did not contain a valid job_id".into(), + request_id, + status_code: Some(status), + })?; + Ok(Job::new_typed(Box::new(FreshnessJob { + inner: RemoteJob::new(self.client.clone(), job_id), + freshness: self.freshness.clone(), + version: self.version.clone(), + tracked_result: TrackedJobResult::MaterializedView, + freshness_request, + }))) + } async fn query_snapshot(&self) -> Result> { let description = self.describe().await?; let TableDescription { @@ -2594,6 +2742,13 @@ impl BaseTable for RemoteTable { query: &AnyQuery, options: QueryExecutionOptions, ) -> Result> { + if let AnyQuery::Query(request) = query + && let Some(offsets) = &request.take_offsets + { + return crate::query::create_take_offsets_plan(self, request, offsets, options, false) + .await; + } + let streams = self.execute_query(query, &options).await?; if streams.len() == 1 { let stream = streams.into_iter().next().unwrap(); @@ -2612,6 +2767,27 @@ impl BaseTable for RemoteTable { query: &AnyQuery, options: QueryExecutionOptions, ) -> Result { + if let AnyQuery::Query(request) = query + && let Some(offsets) = &request.take_offsets + { + let plan = crate::query::create_take_offsets_plan( + self, + request, + offsets, + options.clone(), + false, + ) + .await?; + let inner = execute_plan(plan, Default::default())?; + let inner = MaxBatchLengthStream::new_boxed(inner, options.max_batch_length as usize); + let inner = if let Some(timeout) = options.timeout { + TimeoutStream::new_boxed(inner, timeout) + } else { + inner + }; + return Ok(DatasetRecordBatchStream::new(inner)); + } + let streams = self.execute_query(query, &options).await?; if streams.len() == 1 { @@ -2649,6 +2825,12 @@ impl BaseTable for RemoteTable { } async fn explain_plan(&self, query: &AnyQuery, verbose: bool) -> Result { + if let AnyQuery::Query(request) = query + && let Some(offsets) = &request.take_offsets + { + return crate::query::explain_take_offsets_plan(self, request, offsets, verbose).await; + } + let base_request = self .client .post(&format!("/v1/table/{}/explain_plan/", self.identifier)); @@ -2701,6 +2883,17 @@ impl BaseTable for RemoteTable { query: &AnyQuery, options: QueryExecutionOptions, ) -> Result { + let prepared_query = if let AnyQuery::Query(request) = query + && request.take_offsets.is_some() + { + Some(AnyQuery::Query( + crate::query::prepare_take_offsets_request(self, request).await?, + )) + } else { + None + }; + let query = prepared_query.as_ref().unwrap_or(query); + let mut request = self .client .post(&format!("/v1/table/{}/analyze_plan/", self.identifier)); @@ -2838,7 +3031,7 @@ impl BaseTable for RemoteTable { inner: RemoteJob::new(self.client.clone(), job_id), freshness: self.freshness.clone(), version: self.version.clone(), - track_refresh_result: false, + tracked_result: TrackedJobResult::None, freshness_request: self.snapshot_freshness_headers(), })), None => Job::new_done(), @@ -3321,7 +3514,7 @@ impl BaseTable for RemoteTable { inner: RemoteJob::new(self.client.clone(), response.job_id), freshness: self.freshness.clone(), version: self.version.clone(), - track_refresh_result: true, + tracked_result: TrackedJobResult::RefreshColumn, freshness_request: self.snapshot_freshness_headers(), }))) } @@ -3599,7 +3792,12 @@ impl BaseTable for RemoteTable { #[derive(Serialize, Clone, Debug)] pub struct MergeInsertRequest { - on: String, + // Sent as one repeated `on` query parameter per column, which is how the + // namespace spec encodes an array-valued `on`. serde_urlencoded (which + // reqwest's `query()` uses) cannot serialize a sequence nested in a struct, + // so this field is emitted separately by [`Self::on_query_params`]. + #[serde(skip_serializing)] + on: Vec, when_matched_update_all: bool, when_matched_update_all_filt: Option, when_not_matched_insert_all: bool, @@ -3615,6 +3813,17 @@ pub struct MergeInsertRequest { use_lsm: Option, } +impl MergeInsertRequest { + /// The `on` columns as repeated query parameters: `?on=a&on=b`. + /// + /// A single column serializes to `?on=a`, exactly what clients sent before + /// `on` became a list, so a server that predates composite keys sees no + /// change from a single-column caller. + pub(crate) fn on_query_params(&self) -> Vec<(&str, &str)> { + self.on.iter().map(|col| ("on", col.as_str())).collect() + } +} + fn is_true(b: &bool) -> bool { *b } @@ -3627,12 +3836,15 @@ impl TryFrom for MergeInsertRequest { return Err(Error::InvalidInput { message: "MergeInsertBuilder missing required 'on' field".into(), }); - } else if value.on.len() > 1 { - return Err(Error::NotSupported { - message: "MergeInsertBuilder only supports a single 'on' column".into(), + } + // The server rejects a repeated column with a 400; catching it here + // names the offending column and costs no round trip. + let mut seen = HashSet::with_capacity(value.on.len()); + if let Some(dup) = value.on.iter().find(|col| !seen.insert(*col)) { + return Err(Error::InvalidInput { + message: format!("MergeInsertBuilder 'on' column '{dup}' is repeated"), }); } - let on = value.on[0].clone(); let when_matched_update_all_filt = match value.when_matched_update_all_filt { Some(MergeFilter::Sql(sql)) => Some(sql), @@ -3656,7 +3868,7 @@ impl TryFrom for MergeInsertRequest { }; Ok(Self { - on, + on: value.on, when_matched_update_all: value.when_matched_update_all, when_matched_update_all_filt, when_not_matched_insert_all: value.when_not_matched_insert_all, @@ -3690,7 +3902,7 @@ mod tests { }; use arrow_schema::{DataType, Field, Schema}; use chrono::{DateTime, Utc}; - use futures::{StreamExt, TryFutureExt, future::BoxFuture}; + use futures::{StreamExt, TryFutureExt, TryStreamExt, future::BoxFuture}; use lance_index::scalar::inverted::{DocumentGranularity, query::MatchQuery}; use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams}; use reqwest::Body; @@ -4500,6 +4712,76 @@ mod tests { } } + #[tokio::test] + async fn test_merge_insert_composite_key() { + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(); + let data: Box = Box::new(RecordBatchIterator::new( + [Ok(batch.clone())], + batch.schema(), + )); + + let table = Table::new_with_handler("my_table", move |request| { + assert_eq!(request.url().path(), "/v1/table/my_table/merge_insert/"); + + // One repeated `on` per column, in the order the caller gave them. + let on = request + .url() + .query_pairs() + .filter(|(key, _)| key == "on") + .map(|(_, value)| value.into_owned()) + .collect::>(); + assert_eq!(on, vec!["shard_key".to_string(), "id".to_string()]); + + let params = request.url().query_pairs().collect::>(); + assert_eq!(params["when_matched_update_all"], "true"); + assert_eq!(params["when_not_matched_insert_all"], "true"); + + http::Response::builder() + .status(200) + .body(r#"{"version": 43, "num_deleted_rows": 0, "num_inserted_rows": 3, "num_updated_rows": 0}"#) + .unwrap() + }); + + let mut merge = table.merge_insert(&["shard_key", "id"]); + merge.when_matched_update_all(None); + merge.when_not_matched_insert_all(); + let result = table.base_table().merge_insert(merge, data).await.unwrap(); + + assert_eq!(result.num_inserted_rows, 3); + } + + #[tokio::test] + async fn test_merge_insert_rejects_repeated_on_column() { + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])), + vec![Arc::new(Int32Array::from(vec![1]))], + ) + .unwrap(); + let data: Box = Box::new(RecordBatchIterator::new( + [Ok(batch.clone())], + batch.schema(), + )); + + let table = Table::new_with_handler::<&str>("my_table", |request| { + panic!("Unexpected request: {}", request.url()); + }); + + let merge = table.merge_insert(&["id", "id"]); + let err = table + .base_table() + .merge_insert(merge, data) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("'id' is repeated")), + "unexpected error: {err}" + ); + } + #[tokio::test] async fn test_merge_insert_retries_on_409() { let batch = RecordBatch::try_new( @@ -5611,6 +5893,114 @@ mod tests { assert_eq!(result, "analyzed plan"); } + #[tokio::test] + async fn test_take_offsets_explain_plan_does_not_execute_query() { + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.method(), "POST"); + assert_eq!(request.url().path(), "/v1/table/my_table/explain_plan/"); + + http::Response::builder() + .status(200) + .body(r#""RemoteLookupExec""#) + .unwrap() + }); + + let explained = table + .take_offsets(vec![0, 1, 0, 2]) + .select(crate::query::Select::columns(&["id"])) + .limit(3) + .explain_plan(false) + .await + .unwrap(); + + assert!(explained.contains("GlobalLimitExec")); + assert!(explained.contains("TakeRestoreExec")); + assert!(!explained.contains("CoalescePartitionsExec")); + assert!(explained.contains("RemoteLookupExec")); + } + + #[tokio::test] + async fn test_converted_take_request_restores_remote_occurrences() { + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.method(), "POST"); + assert_eq!(request.url().path(), "/v1/table/my_table/query/"); + + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + assert_eq!(body["columns"], json!(["id", "_rowoffset"])); + + let data = RecordBatch::try_new( + Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("_rowoffset", DataType::UInt64, false), + ])), + vec![ + Arc::new(Int32Array::from(vec![5])), + Arc::new(arrow_array::UInt64Array::from(vec![5])), + ], + ) + .unwrap(); + http::Response::builder() + .status(200) + .header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE) + .body(write_ipc_file(&data)) + .unwrap() + }); + + let request = table + .take_offsets(vec![5, 5]) + .select(crate::query::Select::columns(&["id"])) + .into_request(); + let batches = table + .base_table() + .query(&AnyQuery::Query(request), QueryExecutionOptions::default()) + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + + assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::(), 2); + assert!( + batches + .iter() + .all(|batch| batch.schema().fields().len() == 1) + ); + } + + #[tokio::test] + async fn test_take_offsets_analyze_plan_delegates_to_remote() { + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.method(), "POST"); + assert_eq!(request.url().path(), "/v1/table/my_table/analyze_plan/"); + assert_eq!( + request + .url() + .query_pairs() + .find(|(key, _)| key == "distributed_metrics"), + Some(("distributed_metrics".into(), "per_worker".into())) + ); + + http::Response::builder() + .status(200) + .body(r#""Remote analyzed plan: worker metrics""#) + .unwrap() + }); + + let analyzed = table + .take_offsets(vec![0, 1, 0, 2]) + .select(crate::query::Select::columns(&["id"])) + .limit(3) + .analyze_plan_with_options(QueryExecutionOptions { + analyze_plan_distributed_metrics: AnalyzePlanDistributedMetrics::PerWorker, + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(analyzed, "Remote analyzed plan: worker metrics"); + } + #[tokio::test] async fn test_query_structured_fts() { let table = @@ -5734,9 +6124,8 @@ mod tests { )) .execute() .await; - let err = match result { - Ok(_) => panic!("legacy remote query unexpectedly succeeded"), - Err(err) => err, + let Err(err) = result else { + panic!("legacy remote query unexpectedly succeeded") }; assert!( @@ -5991,6 +6380,34 @@ mod tests { // HNSW_PQ isn't yet supported on SaaS ("BTREE", json!({}), Index::BTree(Default::default())), ("BITMAP", json!({}), Index::Bitmap(Default::default())), + ("ZONEMAP", json!({}), Index::ZoneMap(Default::default())), + ("NGRAM", json!({}), Index::NGram(Default::default())), + ( + "BLOOM_FILTER", + json!({}), + Index::BloomFilter(Default::default()), + ), + ("RTREE", json!({}), Index::RTree(Default::default())), + ( + "BLOOM_FILTER", + json!({"number_of_items": 4096, "probability": 0.01}), + Index::BloomFilter( + crate::index::scalar::BloomFilterIndexBuilder::default() + .number_of_items(4096) + .unwrap() + .probability(0.01) + .unwrap(), + ), + ), + ( + "RTREE", + json!({"page_size": 1024}), + Index::RTree( + crate::index::scalar::RTreeIndexBuilder::default() + .page_size(1024) + .unwrap(), + ), + ), ( "LABEL_LIST", json!({}), @@ -6078,6 +6495,40 @@ mod tests { } } + #[tokio::test] + async fn test_create_index_forwards_replace_false_on_existing_route() { + let table = Table::new_with_handler("my_table", move |request| { + assert_eq!(request.method(), "POST"); + match request.url().path() { + "/v1/table/my_table/describe/" => { + let schema = Schema::new(vec![Field::new("a", DataType::Int32, false)]); + http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap() + } + "/v1/table/my_table/create_index/" => { + let body = request.body().unwrap().as_bytes().unwrap(); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(body["replace"], json!(false)); + + http::Response::builder() + .status(200) + .body("{}".to_string()) + .unwrap() + } + path => panic!("Unexpected path: {}", path), + } + }); + + table + .create_index(&["a"], Index::BTree(Default::default())) + .replace(false) + .execute() + .await + .unwrap(); + } + #[tokio::test] async fn test_create_index_returns_job() { let describe_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -6577,6 +7028,43 @@ mod tests { assert_eq!(indices, expected); } + #[rstest] + #[case::legacy(false)] + #[case::enriched(true)] + #[tokio::test] + async fn test_list_indices_fts_public_list_path(#[case] enriched: bool) { + let schema = nested_index_schema(); + let table = Table::new_with_handler("my_table", move |request| { + let body = match request.url().path() { + "/v1/table/my_table/describe/" => describe_response(&schema), + "/v1/table/my_table/index/list/" => serde_json::json!({ + "indexes": [{ + "index_name": "docs_idx", + "columns": ["docs.item.content"], + "index_type": enriched.then_some("FTS"), + }], + }) + .to_string(), + "/v1/table/my_table/index/docs_idx/stats/" => { + assert!(!enriched, "enriched responses must not fetch index stats"); + serde_json::json!({ + "num_indexed_rows": 1, + "num_unindexed_rows": 0, + "index_type": "FTS", + }) + .to_string() + } + path => panic!("Unexpected path: {path}"), + }; + http::Response::builder().status(200).body(body).unwrap() + }); + + let indices = table.list_indices().await.unwrap(); + assert_eq!(indices.len(), 1); + assert_eq!(indices[0].index_type, IndexType::FTS); + assert_eq!(indices[0].columns, vec!["docs.content"]); + } + #[tokio::test] async fn test_list_indices_nested_field_paths() { let schema = nested_index_schema(); @@ -7449,7 +7937,7 @@ mod tests { }); let application = crate::function::FunctionApplication::from_json( r#"{ - "function":{"name":"embed","version":"fv_01K3EXACT"}, + "function":{"name":"embed","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs":[{"parameter":"text","kind":"column","value":{"path":"description"}}], "output":{"kind":"scalar","arrow_type":"list","nullable":false} }"#, @@ -7465,6 +7953,93 @@ mod tests { assert_eq!(result.version, 8); } + #[tokio::test] + async fn test_add_function_column_allows_an_existing_binding() { + let binding = crate::function::FunctionBinding::from_json(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_function_binding.json" + )) + .unwrap(); + let binding_metadata = crate::table::computed_columns::function_bindings_metadata( + std::slice::from_ref(&binding), + ) + .unwrap(); + let mut fields = vec![ + Field::new("title", DataType::Utf8, true), + Field::new("body", DataType::Utf8, true), + ]; + fields.extend(binding.outputs().iter().map(|output| { + let data_type = match output.arrow_type.as_str() { + "utf8" => DataType::Utf8, + "int64" => DataType::Int64, + other => panic!("unexpected fixture output type {other}"), + }; + Field::new(&output.output_name, data_type, true).with_metadata( + crate::table::computed_columns::function_computed_column_metadata( + binding.binding_id(), + output.output_ordinal, + &["title".into(), "body".into()], + ), + ) + })); + let schema = Schema::new_with_metadata( + fields, + HashMap::from([( + crate::table::computed_columns::FUNCTION_BINDINGS_META_KEY.to_string(), + binding_metadata, + )]), + ); + let table = + Table::new_with_handler("my_table", move |request| match request.url().path() { + "/v1/table/my_table/describe/" => http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap(), + "/v1/table/my_table/add_columns/" => { + let actual: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()) + .unwrap(); + assert_eq!( + actual["new_columns"], + serde_json::json!([ + {"name":"secondary_text","all_null":true}, + {"name":"secondary_token_count","all_null":true} + ]) + ); + http::Response::builder() + .status(200) + .body(r#"{"version":10}"#.to_string()) + .unwrap() + } + path => panic!("Unexpected path: {path}"), + }); + let application = crate::function::FunctionApplication::from_json( + r#"{ + "function":{"name":"text_features","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, + "inputs":[ + {"parameter":"title","kind":"column","value":{"path":"title"}}, + {"parameter":"body","kind":"column","value":{"path":"body"}} + ], + "output":{"kind":"named_struct","fields":[ + {"name":"normalized_text","arrow_type":"utf8","nullable":false}, + {"name":"token_count","arrow_type":"int64","nullable":false} + ]}, + "columns":{ + "normalized_text":"secondary_text", + "token_count":"secondary_token_count" + } + }"#, + ) + .unwrap(); + + let result = table + .add_columns() + .function(application) + .execute() + .await + .unwrap(); + assert_eq!(result.version, 10); + } + #[tokio::test] async fn test_add_fixed_size_list_function_column_declares_the_vector_type() { let table = Table::new_with_handler("my_table", |request| { @@ -7495,7 +8070,7 @@ mod tests { }); let application = crate::function::FunctionApplication::from_json( r#"{ - "function":{"name":"embed","version":"fv_01K3EXACT"}, + "function":{"name":"embed","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs":[{"parameter":"text","kind":"column","value":{"path":"description"}}], "output":{"kind":"scalar","arrow_type":"fixed_size_list","nullable":false} }"#, @@ -7540,7 +8115,7 @@ mod tests { }); let application = crate::function::FunctionApplication::from_json( r#"{ - "function":{"name":"text_features","version":"fv_01K3TEXT"}, + "function":{"name":"text_features","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs":[ {"parameter":"title","kind":"column","value":{"path":"title"}}, {"parameter":"body","kind":"column","value":{"path":"body"}} @@ -7613,6 +8188,72 @@ mod tests { ); } + /// The refresh handle is wrapped for read-freshness tracking, so it has to + /// forward the detail APIs too -- this is the job an operator is holding + /// when a backfill goes quiet. + #[tokio::test] + async fn test_refresh_job_handle_reports_detail_and_events() { + let schema = Arc::new(Schema::new(vec![Field::new( + "state", + DataType::Utf8, + false, + )])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(arrow_array::StringArray::from(vec![ + "claim_complete", + ]))], + ) + .unwrap(); + let mut events = Vec::new(); + { + let mut writer = + arrow_ipc::writer::StreamWriter::try_new(&mut events, &schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + let table = Table::new_with_handler("my_table", move |request| { + match request.url().path() { + "/v1/table/my_table/backfill_column" => http::Response::builder() + .status(202) + .body(br#"{"job_id": "j-42"}"#.to_vec()) + .unwrap(), + "/v1/jobs/describe" => http::Response::builder() + .status(200) + .body( + r#"{"job_id": "j-42", "job_type": "refresh_column", "job_state": "IN_PROGRESS", "creation_ms": 7, "spec": {"column": "doubled"}}"# + .as_bytes() + .to_vec(), + ) + .unwrap(), + "/v1/jobs/query_events" => { + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()) + .unwrap(); + assert_eq!(body["job_id"], "j-42"); + http::Response::builder() + .status(200) + .body(events.clone()) + .unwrap() + } + other => panic!("unexpected path {other}"), + } + }); + + let job = table.refresh_column_async("doubled").await.unwrap(); + job.refresh().await.unwrap(); + assert_eq!(job.state().as_deref(), Some("running")); + assert_eq!(job.job_type().as_deref(), Some("refresh_column")); + assert_eq!(job.creation_ms(), Some(7)); + assert_eq!(job.spec().unwrap()["column"], "doubled"); + + let batches = job + .events(crate::job::JobEventsRequest::default()) + .await + .unwrap(); + assert_eq!(batches.iter().map(|b| b.num_rows()).sum::(), 1); + } + #[tokio::test] async fn test_refresh_submission_uses_add_columns_version_fence() { let table = Table::new_with_handler("my_table", |request| match request.url().path() { @@ -11571,17 +12212,77 @@ mod tests { } #[tokio::test] - async fn test_materialized_view_refused_without_a_request() { - // Materialized views are local-only. The table-level entry the - // bindings use must refuse a remote table before reading its schema, - // so the panicking handler is the assertion. - let table = Table::new_with_handler("my_table", |request| -> http::Response { - panic!("unexpected request: {}", request.url().path()) + async fn test_materialized_view_describe_and_refresh() { + let table = Table::new_with_handler("my_table", |request| match request.url().path() { + "/v1/materialized_view/my_table/describe" => http::Response::builder() + .status(200) + .body( + json!({ + "name": "my_table", + "source_table": "source", + "source_namespace": ["analytics"], + "projections": [{ + "output_column": "double_x", + "expression": "x * 2" + }], + "filter": "x > 0", + "limit": 10, + "inputs": ["x"], + "incarnation": "inc-1" + }) + .to_string(), + ) + .unwrap(), + "/v1/materialized_view/my_table/refresh" => { + assert_eq!(request.method(), "POST"); + assert_eq!( + request_body_json(&request), + json!({ + "full": true, + "source_version": 7, + "expected_incarnation": "inc-1" + }) + ); + http::Response::builder() + .status(202) + .body(json!({"job_id": "j1-mv-refresh"}).to_string()) + .unwrap() + } + "/v1/jobs/describe" => http::Response::builder() + .status(200) + .body( + json!({ + "job_id": "j1-mv-refresh", + "job_state": "DONE", + "result": { + "mode": "rebuild", + "rows_written": 2, + "source_version": 7, + "version": 9 + } + }) + .to_string(), + ) + .unwrap(), + path => panic!("unexpected request: {path}"), }); - let err = crate::MaterializedView::from_table(table) + let view = crate::MaterializedView::from_table(table).await.unwrap(); + assert_eq!(view.definition().source_table, "source"); + assert_eq!(view.definition().source_namespace, ["analytics"]); + assert_eq!(view.definition().inputs, ["x"]); + assert_eq!(view.incarnation(), Some("inc-1")); + + let result = view + .refresh() + .full(true) + .source_version(7) + .expect_incarnation("inc-1") + .execute() .await - .unwrap_err(); - assert!(matches!(err, Error::NotSupported { .. }), "got {err:?}"); + .unwrap(); + assert_eq!(result.mode, crate::RefreshMode::Rebuild); + assert_eq!(result.rows_written, 2); + assert_eq!(result.version, 9); } #[tokio::test] diff --git a/rust/lancedb/src/remote/table/insert.rs b/rust/lancedb/src/remote/table/insert.rs index 4e0e0d666..eef1d8e42 100644 --- a/rust/lancedb/src/remote/table/insert.rs +++ b/rust/lancedb/src/remote/table/insert.rs @@ -734,6 +734,7 @@ impl ExecutionPlan for RemoteWriteExec { WriteOp::MergeInsert { query, timeout } => { let mut request = client .post(&format!("/v1/table/{}/merge_insert/", identifier)) + .query(&query.on_query_params()) .query(query) .header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE); if let Some(timeout) = timeout { @@ -1489,7 +1490,7 @@ mod tests { }); let query = MergeInsertRequest { - on: "id".to_string(), + on: vec!["id".to_string()], when_matched_update_all: false, when_matched_update_all_filt: None, when_not_matched_insert_all: false, diff --git a/rust/lancedb/src/remote/token_cache.rs b/rust/lancedb/src/remote/token_cache.rs new file mode 100644 index 000000000..6f34ab73c --- /dev/null +++ b/rust/lancedb/src/remote/token_cache.rs @@ -0,0 +1,1896 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Persistent OAuth token cache and session lifecycle APIs. +//! +//! By default, OAuth sessions are kept in process memory only (see +//! [`OAuthHeaderProvider`](crate::remote::OAuthHeaderProvider)). Short-lived +//! processes such as CLI tools, notebooks, or scripts would otherwise have to +//! run a full interactive browser or device flow on every start. Configuring +//! [`TokenCacheOptions`] on an [`OAuthConfig`](crate::remote::OAuthConfig) opts +//! in to an explicit, hardened, on-disk cache that stores only the refresh +//! token plus non-secret metadata, so a second process can silently refresh +//! instead of re-prompting. +//! +//! Security properties: +//! +//! - Opt-in only; callers that do not configure a cache stay memory-only. +//! - Only refresh tokens are persisted. Access tokens never touch disk, so +//! there are no local expiry decisions to get wrong when clocks move. +//! - No client secret is ever stored. +//! - The cache directory is private (`0700`) and each record is `0600`, +//! owner-checked, and symlink-rejected on Unix; records are replaced +//! atomically via `rename` so a crash can never leave a torn file. +//! - Cache filenames are SHA-256 hashes of the canonical issuer, client, +//! scope, resource, audience, flow, and client-auth identity. No secret appears +//! in a filename. +//! - Refresh-token rotation is serialized across processes with a per-key +//! advisory file lock (`flock` on Unix, `LockFileEx` on Windows). The +//! operating system releases these locks when a process dies, so a crash +//! cannot leave a stale lock behind. +//! +//! Use [`OAuthSession`] to explicitly `login`, inspect `status`, or `logout` +//! without issuing a database request. +//! +//! # Example +//! +//! ``` +//! use lancedb::remote::{OAuthConfig, OAuthFlow, TokenCacheOptions}; +//! +//! # async fn example() -> Result<(), Box> { +//! let config = OAuthConfig { +//! issuer_url: "https://issuer.example.com".to_string(), +//! client_id: "my-app".to_string(), +//! client_secret: None, +//! scopes: vec!["openid".to_string()], +//! flow: OAuthFlow::DeviceCode, +//! client_auth_method: None, +//! refresh_buffer_secs: None, +//! resource: Some("https://api.example.com".to_string()), +//! audience: None, +//! token_cache: Some( +//! TokenCacheOptions::new().cache_dir("/tmp/my-app/oauth-cache"), +//! ), +//! }; +//! let session = lancedb::remote::OAuthSession::new(config)?; +//! session.login().await?; +//! let status = session.status().await?; +//! assert!(status.refreshable); +//! # Ok(()) +//! # } +//! ``` + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use fs4::fs_std::FileExt; +use log::{debug, warn}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::error::{Error, Result}; +use crate::remote::oauth::{OAuthConfig, OAuthFlow, RefreshResult, TokenResponse, TokenSource}; + +const CACHE_RECORD_VERSION: u32 = 1; +const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 30; +const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100); + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +/// Options for the persistent OAuth token cache. +/// +/// The cache is opt-in: it is only used when set on +/// [`OAuthConfig::token_cache`](crate::remote::OAuthConfig::token_cache). See +/// the [module documentation](self) for the security properties. +#[derive(Clone, Debug, Default)] +pub struct TokenCacheOptions { + /// Directory that holds the cached credentials. + /// + /// Defaults to `$XDG_CACHE_HOME/lancedb/oauth`, `$HOME/.cache/lancedb/oauth` + /// on Unix, or `%LOCALAPPDATA%\lancedb\oauth` on Windows. The directory is + /// created with owner-only permissions (`0700`) when missing. + pub cache_dir: Option, + + /// How long to wait for the cross-process refresh lock before failing. + /// + /// Defaults to 30 seconds. + pub lock_timeout_secs: Option, +} + +impl TokenCacheOptions { + /// Create cache options with all defaults. + pub fn new() -> Self { + Self::default() + } + + /// Set the directory that holds cached credentials. + pub fn cache_dir(mut self, cache_dir: impl Into) -> Self { + self.cache_dir = Some(cache_dir.into()); + self + } + + /// Set the cross-process refresh lock timeout in seconds. + pub fn lock_timeout_secs(mut self, secs: u64) -> Self { + self.lock_timeout_secs = Some(secs); + self + } + + fn resolved_dir(&self) -> Result { + if let Some(dir) = &self.cache_dir { + if dir.as_os_str().is_empty() { + return Err(Error::InvalidInput { + message: "OAuth token cache directory must not be empty".to_string(), + }); + } + return Ok(dir.clone()); + } + default_cache_dir().ok_or_else(|| Error::InvalidInput { + message: "Could not determine a default OAuth token cache directory; \ + set TokenCacheOptions::cache_dir or XDG_CACHE_HOME/HOME" + .to_string(), + }) + } + + fn lock_timeout(&self) -> Duration { + Duration::from_secs(self.lock_timeout_secs.unwrap_or(DEFAULT_LOCK_TIMEOUT_SECS)) + } +} + +#[cfg(unix)] +fn default_cache_dir() -> Option { + let base = std::env::var_os("XDG_CACHE_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(|home| { + let mut path = PathBuf::from(home); + path.push(".cache"); + path + }) + })?; + let mut dir = base; + dir.push("lancedb"); + dir.push("oauth"); + Some(dir) +} + +#[cfg(windows)] +fn default_cache_dir() -> Option { + let base = std::env::var_os("LOCALAPPDATA") + .filter(|value| !value.is_empty()) + .map(PathBuf::from)?; + let mut dir = base; + dir.push("lancedb"); + dir.push("oauth"); + Some(dir) +} + +#[cfg(not(any(unix, windows)))] +fn default_cache_dir() -> Option { + None +} + +/// A cached OAuth session record. +/// +/// Only the refresh token is persisted. The metadata mirrors the cache key so +/// `status` can report what a record belongs to without exposing secrets. +#[derive(Serialize, Deserialize)] +struct CachedTokenRecord { + version: u32, + issuer_url: String, + client_id: String, + scopes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + resource: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + audience: Option, + flow: String, + client_auth: String, + refresh_token: String, + obtained_at: u64, +} + +impl std::fmt::Debug for CachedTokenRecord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CachedTokenRecord") + .field("version", &self.version) + .field("issuer_url", &self.issuer_url) + .field("client_id", &self.client_id) + .field("scopes", &self.scopes) + .field("resource", &self.resource) + .field("audience", &self.audience) + .field("flow", &self.flow) + .field("client_auth", &self.client_auth) + .field("refresh_token", &"") + .field("obtained_at", &self.obtained_at) + .finish() + } +} + +fn canonicalize_issuer(issuer_url: &str) -> String { + issuer_url.trim_end_matches('/').to_string() +} + +fn canonicalize_scopes(scopes: &[String]) -> Vec { + let mut canonical: Vec = scopes + .iter() + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect(); + canonical.sort(); + canonical.dedup(); + canonical +} + +/// Returns the cache identity of a flow, or `None` for flows that never +/// persist: client credentials have no refresh token to store, and Azure +/// managed identity is machine identity that must not enter a user token +/// cache (rejected separately with an explicit error). +fn flow_key(flow: &OAuthFlow) -> Option<&'static str> { + match flow { + OAuthFlow::AuthorizationCode(_) => Some("authorization_code"), + OAuthFlow::DeviceCode => Some("device_code"), + OAuthFlow::ClientCredentials | OAuthFlow::AzureManagedIdentity { .. } => None, + } +} + +fn client_auth_key(client_secret: Option<&str>) -> &'static str { + if client_secret.is_some() { + "confidential" + } else { + "public" + } +} + +/// Identity of one cached session: canonical issuer, client, scopes, resource, +/// audience, flow, and client-auth mode, plus the hashed filename derived from it. +#[derive(Clone, Debug)] +struct CacheKey { + issuer_url: String, + client_id: String, + scopes: Vec, + resource: Option, + audience: Option, + flow: &'static str, + client_auth: &'static str, + file_stem: String, +} + +impl CacheKey { + fn new(config: &OAuthConfig) -> Result { + let flow = flow_key(&config.flow).ok_or_else(|| Error::InvalidInput { + message: format!( + "A persistent OAuth token cache is not supported for the {:?} flow; \ + remove TokenCacheOptions to keep tokens in memory", + config.flow + ), + })?; + let issuer_url = canonicalize_issuer(&config.issuer_url); + let scopes = canonicalize_scopes(&config.scopes); + let client_auth = client_auth_key(config.client_secret.as_deref()); + let identity = format!( + "v1\n{}\n{}\n{}\n{}\n{}", + issuer_url, + config.client_id, + scopes.join(" "), + flow, + client_auth + ); + // Keep existing sessions reachable when no target was specified. Targeted + // sessions use a structured encoding so parameter contents cannot collide. + let identity = if config.resource.is_none() && config.audience.is_none() { + identity + } else { + serde_json::to_string(&( + "v2", + &issuer_url, + &config.client_id, + &scopes, + flow, + client_auth, + &config.resource, + &config.audience, + )) + .map_err(|error| Error::Runtime { + message: format!("Failed to encode OAuth cache identity: {error}"), + })? + }; + let file_stem = hex_sha256(identity.as_bytes()); + Ok(Self { + issuer_url, + client_id: config.client_id.clone(), + scopes, + resource: config.resource.clone(), + audience: config.audience.clone(), + flow, + client_auth, + file_stem, + }) + } +} + +fn hex_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write; + let _ = write!(hex, "{byte:02x}"); + } + hex +} + +/// Guard for the per-key cross-process refresh lock. +/// +/// The lock is an advisory exclusive lock on a per-key file. The operating +/// system releases it when the owning process exits, so crashes cannot strand +/// a stale lock. +struct LockGuard { + #[allow(dead_code)] + file: std::fs::File, +} + +/// The persistent token cache engine for one [`OAuthConfig`]. +pub struct TokenCache { + dir: PathBuf, + key: CacheKey, + lock_timeout: Duration, + #[cfg(unix)] + dir_owner: u32, +} + +impl std::fmt::Debug for TokenCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TokenCache") + .field("dir", &self.dir) + .field("flow", &self.key.flow) + .finish() + } +} + +impl TokenCache { + fn new(config: &OAuthConfig, options: &TokenCacheOptions) -> Result { + let key = CacheKey::new(config)?; + let dir = options.resolved_dir()?; + let lock_timeout = options.lock_timeout(); + prepare_cache_dir(&dir)?; + #[cfg(unix)] + let dir_owner = { + use std::os::unix::fs::MetadataExt; + let metadata = std::fs::metadata(&dir).map_err(|e| Error::Runtime { + message: format!( + "Failed to inspect OAuth token cache directory {}: {e}", + dir.display() + ), + })?; + metadata.uid() + }; + Ok(Self { + dir, + key, + lock_timeout, + #[cfg(unix)] + dir_owner, + }) + } + + fn record_path(&self) -> PathBuf { + let mut path = self.dir.clone(); + path.push(format!("{}.token.json", self.key.file_stem)); + path + } + + fn lock_path(&self) -> PathBuf { + let mut path = self.dir.clone(); + path.push(format!("{}.lock", self.key.file_stem)); + path + } + + /// Load the cached record, if one exists and passes hardening checks. + /// + /// Corrupt, truncated, unknown-version, or permission-invalid records + /// return an actionable error instead of being silently ignored or + /// deleted; the message names the file and how to recover. + async fn load(&self) -> Result> { + let path = self.record_path(); + let dir_owner = self.dir_owner_or_zero(); + tokio::task::spawn_blocking(move || read_record(&path, dir_owner)) + .await + .map_err(|e| Error::Runtime { + message: format!("Failed to join OAuth token cache read: {e}"), + })? + } + + #[cfg(unix)] + fn dir_owner_or_zero(&self) -> u32 { + self.dir_owner + } + + #[cfg(not(unix))] + fn dir_owner_or_zero(&self) -> u32 { + 0 + } + + /// Build a record from a token response, or `None` when the response + /// carries no refresh token (nothing may be persisted). + fn record_from_response(&self, response: &TokenResponse) -> Option { + let refresh_token = response + .refresh_token + .as_ref() + .map(|token| token.secret().clone())?; + Some(CachedTokenRecord { + version: CACHE_RECORD_VERSION, + issuer_url: self.key.issuer_url.clone(), + client_id: self.key.client_id.clone(), + scopes: self.key.scopes.clone(), + resource: self.key.resource.clone(), + audience: self.key.audience.clone(), + flow: self.key.flow.to_string(), + client_auth: self.key.client_auth.to_string(), + refresh_token, + obtained_at: now_unix_secs(), + }) + } + + /// Atomically replace the cached record. + async fn store(&self, record: &CachedTokenRecord) -> Result<()> { + let path = self.record_path(); + let dir = self.dir.clone(); + let payload = serde_json::to_vec(record).map_err(|e| Error::Runtime { + message: format!("Failed to serialize OAuth token cache record: {e}"), + })?; + tokio::task::spawn_blocking(move || write_record(&dir, &path, &payload)) + .await + .map_err(|e| Error::Runtime { + message: format!("Failed to join OAuth token cache write: {e}"), + })? + } + + /// Delete the cached record. Returns whether a record was removed. + async fn delete(&self) -> Result { + let path = self.record_path(); + tokio::task::spawn_blocking(move || match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(Error::Runtime { + message: format!( + "Failed to remove OAuth token cache record {}: {e}", + path.display() + ), + }), + }) + .await + .map_err(|e| Error::Runtime { + message: format!("Failed to join OAuth token cache delete: {e}"), + })? + } + + /// Acquire the per-key cross-process lock, polling until the timeout. + async fn acquire_lock(&self) -> Result { + let path = self.lock_path(); + let timeout = self.lock_timeout; + tokio::task::spawn_blocking(move || { + use std::fs::OpenOptions; + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .map_err(|e| Error::Runtime { + message: format!( + "Failed to open OAuth token cache lock file {}: {e}", + path.display() + ), + })?; + #[cfg(unix)] + set_owner_only_permissions(&file, &path); + let deadline = std::time::Instant::now() + timeout; + loop { + match file.try_lock_exclusive() { + Ok(true) => return Ok(LockGuard { file }), + Ok(false) => { + if std::time::Instant::now() >= deadline { + return Err(Error::Runtime { + message: format!( + "Timed out after {}s waiting for the OAuth token cache \ + lock at {}", + timeout.as_secs(), + path.display() + ), + }); + } + std::thread::sleep(LOCK_POLL_INTERVAL); + } + Err(error) => { + return Err(Error::Runtime { + message: format!( + "Failed to lock the OAuth token cache file {}: {error}", + path.display() + ), + }); + } + } + } + }) + .await + .map_err(|e| Error::Runtime { + message: format!("Failed to join OAuth token cache lock acquisition: {e}"), + })? + } + + /// Run the cross-process refresh critical section. + /// + /// Must be called with the in-process write lock held. Refresh grants are + /// serialized by the per-key cross-process lock; interactive flows (first + /// login or reauthentication) run outside it so a slow human-in-the-loop + /// flow never blocks refreshes in other processes. + pub(crate) async fn refresh_or_acquire( + &self, + source: &dyn TokenSource, + ) -> Result { + // Fast path under the lock: refresh from the durable record. + { + let _guard = self.acquire_lock().await?; + // Reread the record: another process may have rotated the refresh + // token since this process last looked. + if let Some(record) = self.load().await? { + match source.refresh_token(&record.refresh_token).await? { + RefreshResult::Refreshed(response) => { + self.store_if_refreshable(&response).await?; + return Ok(response); + } + RefreshResult::Reauthenticate => { + warn!( + "Cached OAuth refresh token was rejected; removing the cached \ + session before reauthenticating via {:?}", + source + ); + self.delete().await?; + } + RefreshResult::Unsupported => {} + } + } else { + debug!("No cached OAuth session; acquiring one via {:?}", source); + } + } + + // Interactive acquisition happens without the cross-process lock. + // Concurrent logins are independent sessions; the last store wins, + // which is the documented multi-session rule. + let response = source.fetch_token().await?; + let _guard = self.acquire_lock().await?; + self.store_if_refreshable(&response).await?; + Ok(response) + } + + /// Store a fresh login response (used by the eager `login` API). + /// + /// A successful login atomically replaces any prior session for this + /// cache identity: when the provider does not issue a refresh token, the + /// previous record is removed rather than left in place, so logging in + /// can never silently keep an earlier account's credential. + async fn store_login_response(&self, response: &TokenResponse) -> Result<()> { + let _guard = self.acquire_lock().await?; + match self.record_from_response(response) { + Some(record) => self.store(&record).await?, + None => { + self.delete().await?; + } + } + Ok(()) + } + + /// Replace the record when the response carries a refresh token. + /// + /// Called with the cross-process lock already held. Responses without a + /// refresh token are not persistable and are skipped. + async fn store_if_refreshable(&self, response: &TokenResponse) -> Result<()> { + if let Some(record) = self.record_from_response(response) { + self.store(&record).await?; + } else { + debug!( + "OAuth response did not include a refresh token; nothing to cache for {:?}", + self.key.flow + ); + } + Ok(()) + } +} + +fn prepare_cache_dir(dir: &Path) -> Result<()> { + if dir.is_dir() { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata = std::fs::metadata(dir).map_err(|e| Error::Runtime { + message: format!( + "Failed to inspect OAuth token cache directory {}: {e}", + dir.display() + ), + })?; + let mode = metadata.mode(); + if mode & 0o077 != 0 { + return Err(Error::InvalidInput { + message: format!( + "OAuth token cache directory {} must not be accessible by group or \ + other users (mode {:o}); run `chmod 700` on it or choose a \ + private directory", + dir.display(), + mode & 0o777 + ), + }); + } + } + return Ok(()); + } + if dir.exists() { + return Err(Error::InvalidInput { + message: format!( + "OAuth token cache path {} exists and is not a directory", + dir.display() + ), + }); + } + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(dir).map_err(|e| Error::Runtime { + message: format!( + "Failed to create OAuth token cache directory {}: {e}", + dir.display() + ), + }) +} + +fn read_record(path: &Path, dir_owner: u32) -> Result> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(Error::Runtime { + message: format!( + "Failed to read OAuth token cache record {}: {e}", + path.display() + ), + }); + } + }; + if !metadata.is_file() { + return Err(Error::InvalidInput { + message: format!( + "OAuth token cache record {} is not a regular file; refusing to use it. \ + Remove the file or call logout to clear it", + path.display() + ), + }); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let mode = metadata.mode(); + if mode & 0o077 != 0 { + return Err(Error::InvalidInput { + message: format!( + "OAuth token cache record {} must not be accessible by group or other \ + users (mode {:o}); run `chmod 600` on it or call logout to clear it", + path.display(), + mode & 0o777 + ), + }); + } + if dir_owner != 0 && metadata.uid() != dir_owner { + return Err(Error::InvalidInput { + message: format!( + "OAuth token cache record {} is owned by a different user; refusing to \ + use it. Remove the file or call logout to clear it", + path.display() + ), + }); + } + } + let payload = std::fs::read_to_string(path).map_err(|e| Error::Runtime { + message: format!( + "Failed to read OAuth token cache record {}: {e}", + path.display() + ), + })?; + let record: CachedTokenRecord = serde_json::from_str(&payload).map_err(|e| Error::Runtime { + message: format!( + "OAuth token cache record {} is corrupt ({e}); remove the file or call \ + logout to clear it", + path.display() + ), + })?; + if record.version != CACHE_RECORD_VERSION { + return Err(Error::Runtime { + message: format!( + "OAuth token cache record {} has unsupported version {}; expected {}. \ + Remove the file or call logout to clear it", + path.display(), + record.version, + CACHE_RECORD_VERSION + ), + }); + } + Ok(Some(record)) +} + +fn write_record(dir: &Path, path: &Path, payload: &[u8]) -> Result<()> { + let random_suffix: String = { + use rand::Rng; + let mut rng = rand::rng(); + (0..8) + .map(|_| format!("{:x}", rng.random_range(0..16u32))) + .collect() + }; + let mut temp_path = dir.to_path_buf(); + temp_path.push(format!( + "{}.tmp.{}.{}", + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(), + std::process::id(), + random_suffix + )); + let write_attempt = || -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + let file = options.open(&temp_path)?; + #[cfg(unix)] + set_owner_only_permissions(&file, &temp_path); + use std::io::Write; + let mut writer = std::io::BufWriter::new(&file); + writer.write_all(payload)?; + writer.flush()?; + drop(writer); + file.sync_all()?; + std::fs::rename(&temp_path, path)?; + Ok(()) + }; + write_attempt().map_err(|e| { + let _ = std::fs::remove_file(&temp_path); + Error::Runtime { + message: format!( + "Failed to write OAuth token cache record {}: {e}", + path.display() + ), + } + }) +} + +#[cfg(unix)] +fn set_owner_only_permissions(file: &std::fs::File, path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = file.set_permissions(std::fs::Permissions::from_mode(0o600)) { + debug!("Could not restrict permissions on {}: {e}", path.display()); + } +} + +/// Safe, non-secret view of a cached OAuth session, returned by +/// [`OAuthSession::status`] and [`OAuthSession::login`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionStatus { + /// Whether a cached session exists that can obtain tokens without + /// interactive authentication. + /// + /// Because access tokens are not persisted, this is `true` exactly when a + /// refresh token is cached; the next connection refreshes with it rather + /// than opening a browser or device prompt. + pub refreshable: bool, + + /// Canonical issuer URL of the cached session. + pub issuer_url: String, + + /// Client ID of the cached session. + pub client_id: String, + + /// Canonical (sorted, de-duplicated) scope set of the cached session. + pub scopes: Vec, + + /// Resource indicator used to obtain the cached session, if configured. + pub resource: Option, + + /// Provider-specific audience used to obtain the cached session, if configured. + pub audience: Option, + + /// Flow that produced the cached session. + pub flow: String, + + /// When the cached session was obtained, as Unix seconds. + pub obtained_at: Option, +} + +/// Result of [`OAuthSession::logout`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionLogout { + /// Whether a cached credential was removed. `false` means no matching + /// session was cached; logout is idempotent. + pub removed: bool, +} + +/// Explicit OAuth session lifecycle: eager `login`, non-secret `status`, and +/// local `logout` for the persistent token cache. +/// +/// A session is built from the same [`OAuthConfig`](crate::remote::OAuthConfig) +/// used to connect (including its +/// [`token_cache`](crate::remote::OAuthConfig::token_cache) options). A +/// connection created with the same configuration shares the cache, so logging +/// in here prepares tokens for later processes without any database request. +/// +/// `login` always runs the configured interactive flow and replaces the +/// cached session (the most recent login wins; see the module documentation +/// about multiple accounts). `logout` removes only the local credential; it +/// does not revoke anything with the provider and does not terminate a +/// browser SSO session. +/// +/// # Example +/// +/// ``` +/// # use lancedb::remote::{OAuthConfig, OAuthFlow, OAuthSession, TokenCacheOptions}; +/// # fn example() -> lancedb::error::Result<()> { +/// let config = OAuthConfig { +/// issuer_url: "https://issuer.example.com".to_string(), +/// client_id: "my-app".to_string(), +/// client_secret: None, +/// scopes: vec!["openid".to_string()], +/// flow: OAuthFlow::DeviceCode, +/// client_auth_method: None, +/// refresh_buffer_secs: None, +/// resource: None, +/// audience: None, +/// token_cache: Some(TokenCacheOptions::new()), +/// }; +/// let session = OAuthSession::new(config)?; +/// # Ok(()) +/// # } +/// ``` +pub struct OAuthSession { + token_source: Box, + cache: TokenCache, +} + +impl std::fmt::Debug for OAuthSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuthSession") + .field("cache", &self.cache) + .finish() + } +} + +impl OAuthSession { + /// Create a session manager for the given configuration. + /// + /// The configuration must enable [`TokenCacheOptions`] and use a flow + /// that supports persistent sessions (authorization code or device + /// authorization). + pub fn new(config: OAuthConfig) -> Result { + let cache_options = config + .token_cache + .clone() + .ok_or_else(|| Error::InvalidInput { + message: "OAuthSession requires OAuthConfig.token_cache to be set".to_string(), + })?; + if config.scopes.is_empty() { + return Err(Error::InvalidInput { + message: "At least one OAuth scope is required".to_string(), + }); + } + let token_source = crate::remote::oauth::build_token_source(&config)?; + let cache = TokenCache::new(&config, &cache_options)?; + Ok(Self { + token_source, + cache, + }) + } + + /// Eagerly run the configured authentication flow and store the session. + /// + /// Returns the resulting [`SessionStatus`]. A successful login always + /// replaces any prior cached session for this identity; if the provider + /// does not issue a refresh token (for example without `offline_access`), + /// the previous record is removed and the status reports + /// `refreshable == false`. + pub async fn login(&self) -> Result { + let response = self.token_source.fetch_token().await?; + self.cache.store_login_response(&response).await?; + self.status().await + } + + /// Report whether a matching cached session exists, with safe metadata. + /// + /// This never contacts the identity provider and never exposes token + /// values. + pub async fn status(&self) -> Result { + let record = self.cache.load().await?; + Ok(match record { + Some(record) => SessionStatus { + refreshable: true, + issuer_url: record.issuer_url, + client_id: record.client_id, + scopes: record.scopes, + resource: record.resource, + audience: record.audience, + flow: record.flow, + obtained_at: Some(record.obtained_at), + }, + None => SessionStatus { + refreshable: false, + issuer_url: self.cache.key.issuer_url.clone(), + client_id: self.cache.key.client_id.clone(), + scopes: self.cache.key.scopes.clone(), + resource: self.cache.key.resource.clone(), + audience: self.cache.key.audience.clone(), + flow: self.cache.key.flow.to_string(), + obtained_at: None, + }, + }) + } + + /// Remove the matching local cached credential. + /// + /// This only deletes the local cache entry. It does not revoke the + /// refresh token with the provider and does not sign out of a browser + /// SSO session. Repeated calls succeed; `removed` reports whether a + /// credential existed. + pub async fn logout(&self) -> Result { + let removed = self.cache.delete().await?; + Ok(SessionLogout { removed }) + } +} + +/// Resolve the persistent cache for a configuration, if one should exist. +/// +/// Returns `Ok(None)` for configurations without cache options and for the +/// client-credentials flow, which has no refresh token to persist (a debug +/// note is logged). The Azure managed-identity flow is rejected because +/// machine identity must not enter a user token cache. +pub fn token_cache_for_config(config: &OAuthConfig) -> Result>> { + let Some(options) = &config.token_cache else { + return Ok(None); + }; + if matches!(config.flow, OAuthFlow::AzureManagedIdentity { .. }) { + return Err(Error::InvalidInput { + message: "A persistent OAuth token cache cannot be used with the \ + AzureManagedIdentity flow; remove TokenCacheOptions to keep the \ + machine identity token in memory" + .to_string(), + }); + } + if matches!(config.flow, OAuthFlow::ClientCredentials) { + debug!( + "The client-credentials flow has no refresh token to persist; the OAuth \ + token cache is not used" + ); + return Ok(None); + } + Ok(Some(Arc::new(TokenCache::new(config, options)?))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + + use crate::remote::HeaderProvider; + use crate::remote::oauth::OAuthHeaderProvider; + use oauth2::basic::BasicTokenType; + use oauth2::{AccessToken, RefreshToken}; + use serial_test::serial; + + /// Temp directory that satisfies the cache hardening checks. CI runners + /// can create temp directories with group/other bits set, which the + /// private-directory validation correctly rejects. + fn cache_tempdir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + dir + } + + fn device_config(cache_dir: &Path) -> OAuthConfig { + OAuthConfig { + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + client_secret: None, + client_auth_method: None, + scopes: vec!["openid".to_string()], + flow: OAuthFlow::DeviceCode, + refresh_buffer_secs: None, + resource: None, + audience: None, + token_cache: Some(TokenCacheOptions::new().cache_dir(cache_dir)), + } + } + + /// Stateful mock IdP covering discovery, device authorization, device + /// polling, client credentials, and refresh with strict rotation: a + /// refresh token that is not the currently issued one is rejected with + /// `invalid_grant`, which is exactly what real providers do on rotation. + struct MockIdp { + issuer_url: String, + requests: Arc>>, + device_authorizations: Arc, + refresh_attempts: Arc, + invalid_grant_rejections: Arc, + access_tokens_issued: Arc, + current_refresh: Arc>>, + fail_refreshes: Arc, + issue_refresh_tokens: Arc, + } + + impl MockIdp { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer_url = format!("http://{addr}"); + let server = Self { + issuer_url: issuer_url.clone(), + requests: Arc::new(std::sync::Mutex::new(Vec::new())), + device_authorizations: Arc::new(AtomicUsize::new(0)), + refresh_attempts: Arc::new(AtomicUsize::new(0)), + invalid_grant_rejections: Arc::new(AtomicUsize::new(0)), + access_tokens_issued: Arc::new(AtomicUsize::new(0)), + current_refresh: Arc::new(std::sync::Mutex::new(None)), + fail_refreshes: Arc::new(AtomicBool::new(false)), + issue_refresh_tokens: Arc::new(AtomicBool::new(true)), + }; + let requests = Arc::clone(&server.requests); + let device_authorizations = Arc::clone(&server.device_authorizations); + let refresh_attempts = Arc::clone(&server.refresh_attempts); + let invalid_grant_rejections = Arc::clone(&server.invalid_grant_rejections); + let access_tokens_issued = Arc::clone(&server.access_tokens_issued); + let current_refresh = Arc::clone(&server.current_refresh); + let fail_refreshes = Arc::clone(&server.fail_refreshes); + let issue_refresh_tokens = Arc::clone(&server.issue_refresh_tokens); + + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let requests = Arc::clone(&requests); + let device_authorizations = Arc::clone(&device_authorizations); + let refresh_attempts = Arc::clone(&refresh_attempts); + let invalid_grant_rejections = Arc::clone(&invalid_grant_rejections); + let access_tokens_issued = Arc::clone(&access_tokens_issued); + let current_refresh = Arc::clone(¤t_refresh); + let fail_refreshes = Arc::clone(&fail_refreshes); + let issue_refresh_tokens = Arc::clone(&issue_refresh_tokens); + tokio::spawn(async move { + let (request_line, body) = read_http_request(&mut stream).await; + if request_line.starts_with("POST ") { + requests.lock().unwrap().push(body.clone()); + } + if request_line.starts_with("GET /.well-known/openid-configuration ") { + let discovery = format!( + r#"{{"token_endpoint":"http://{addr}/token","device_authorization_endpoint":"http://{addr}/device"}}"# + ); + write_json_response(&mut stream, "200 OK", &discovery).await; + } else if request_line.starts_with("POST /device ") { + device_authorizations.fetch_add(1, Ordering::SeqCst); + let device = format!( + r#"{{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://{addr}/verify","expires_in":60,"interval":1}}"# + ); + write_json_response(&mut stream, "200 OK", &device).await; + } else if request_line.starts_with("POST /token ") { + if body.contains("grant_type=refresh_token") { + refresh_attempts.fetch_add(1, Ordering::SeqCst); + if fail_refreshes.load(Ordering::SeqCst) { + write_json_response( + &mut stream, + "503 Service Unavailable", + r#"{"error":"temporarily_unavailable"}"#, + ) + .await; + return; + } + let expected = current_refresh.lock().unwrap().clone(); + let matched = body + .split('&') + .find_map(|pair| pair.strip_prefix("refresh_token=")) + .map(|token| token.to_string()) + .zip(expected) + .is_some_and(|(offered, expected)| offered == expected); + if !matched { + invalid_grant_rejections.fetch_add(1, Ordering::SeqCst); + write_json_response( + &mut stream, + "400 Bad Request", + r#"{"error":"invalid_grant"}"#, + ) + .await; + return; + } + issue_access_token( + &mut stream, + &access_tokens_issued, + ¤t_refresh, + issue_refresh_tokens.load(Ordering::SeqCst), + ) + .await; + } else { + // Device polling or client credentials: issue + // a token and, for interactive grants, a fresh + // refresh token with strict rotation. + let grant_device = + body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"); + if grant_device { + issue_access_token( + &mut stream, + &access_tokens_issued, + ¤t_refresh, + issue_refresh_tokens.load(Ordering::SeqCst), + ) + .await; + } else { + let token = format!( + r#"{{"access_token":"access-{}","expires_in":3600}}"#, + access_tokens_issued.fetch_add(1, Ordering::SeqCst) + 1 + ); + write_json_response(&mut stream, "200 OK", &token).await; + } + } + } else { + write_json_response(&mut stream, "404 Not Found", "{}").await; + } + }); + } + }); + server + } + + fn config(&self, cache_dir: &Path) -> OAuthConfig { + let mut config = device_config(cache_dir); + config.issuer_url = self.issuer_url.clone(); + config + } + } + + async fn issue_access_token( + stream: &mut TcpStream, + access_tokens_issued: &AtomicUsize, + current_refresh: &std::sync::Mutex>, + with_refresh: bool, + ) { + let number = access_tokens_issued.fetch_add(1, Ordering::SeqCst) + 1; + let token = if with_refresh { + *current_refresh.lock().unwrap() = Some(format!("refresh-{number}")); + format!( + r#"{{"access_token":"access-{number}","refresh_token":"refresh-{number}","expires_in":3600}}"# + ) + } else { + format!(r#"{{"access_token":"access-{number}","expires_in":3600}}"#) + }; + write_json_response(stream, "200 OK", &token).await; + } + + async fn read_http_request(stream: &mut TcpStream) -> (String, String) { + let mut buffer = Vec::new(); + let mut header_end = None; + while header_end.is_none() { + let mut chunk = [0; 1024]; + let read = stream.read(&mut chunk).await.unwrap(); + assert_ne!(read, 0, "connection closed before request headers"); + buffer.extend_from_slice(&chunk[..read]); + header_end = find_subsequence(&buffer, b"\r\n\r\n").map(|pos| pos + 4); + } + let header_end = header_end.unwrap(); + let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string(); + let request_line = headers.lines().next().unwrap_or_default().to_string(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while buffer.len() < header_end + content_length { + let mut chunk = [0; 1024]; + let read = stream.read(&mut chunk).await.unwrap(); + assert_ne!(read, 0, "connection closed before request body"); + buffer.extend_from_slice(&chunk[..read]); + } + let body = + String::from_utf8_lossy(&buffer[header_end..header_end + content_length]).to_string(); + (request_line, body) + } + + fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) + } + + async fn write_json_response(stream: &mut TcpStream, status: &str, body: &str) { + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + + fn suppress_browser() { + // Point the OAuth browser helper at a no-op so device-flow tests never + // open a real browser window. + unsafe { std::env::set_var("LANCEDB_OAUTH_BROWSER", "/usr/bin/true") }; + } + + #[tokio::test] + #[serial] + async fn test_provider_reuses_cached_session_across_instances() { + suppress_browser(); + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + + let first = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + let headers = first.get_headers().await.unwrap(); + assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1"); + + // A second, independent provider (simulating a second process) must + // refresh silently instead of starting another device flow. + let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + let headers = second.get_headers().await.unwrap(); + assert_eq!(headers.get("authorization").unwrap(), "Bearer access-2"); + + assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1); + assert_eq!(idp.refresh_attempts.load(Ordering::SeqCst), 1); + + let cache = crate::remote::token_cache::TokenCache::new( + &idp.config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let record = cache.load().await.unwrap().unwrap(); + assert_eq!(record.refresh_token, "refresh-2"); + } + + #[tokio::test] + #[serial] + async fn test_concurrent_providers_serialize_rotation() { + suppress_browser(); + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + + let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + priming.get_headers().await.unwrap(); + assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1); + + let provider_a = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + let provider_b = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + let (headers_a, headers_b) = + tokio::join!(provider_a.get_headers(), provider_b.get_headers()); + let token_a = headers_a.unwrap().remove("authorization").unwrap(); + let token_b = headers_b.unwrap().remove("authorization").unwrap(); + assert!( + { + let mut tokens = [token_a, token_b]; + tokens.sort(); + tokens + } == ["Bearer access-2".to_string(), "Bearer access-3".to_string()], + "each provider must observe its own refreshed token" + ); + + // Rotation raced would produce an invalid_grant and a second device + // flow; the lock prevents both. + assert_eq!(idp.invalid_grant_rejections.load(Ordering::SeqCst), 0); + assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1); + assert_eq!(idp.refresh_attempts.load(Ordering::SeqCst), 2); + + let cache = crate::remote::token_cache::TokenCache::new( + &idp.config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let record = cache.load().await.unwrap().unwrap(); + assert_eq!(record.refresh_token, "refresh-3"); + } + + #[tokio::test] + #[serial] + async fn test_transient_refresh_failure_retains_record() { + suppress_browser(); + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + + let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + priming.get_headers().await.unwrap(); + + idp.fail_refreshes.store(true, Ordering::SeqCst); + let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + let error = second.get_headers().await.unwrap_err(); + assert!(error.to_string().contains("503")); + + let session = OAuthSession::new(idp.config(dir.path())).unwrap(); + let status = session.status().await.unwrap(); + assert!( + status.refreshable, + "transient failures must keep the record" + ); + } + + #[tokio::test] + #[serial] + async fn test_invalid_grant_deletes_record_and_reauthenticates() { + suppress_browser(); + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + + let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + priming.get_headers().await.unwrap(); + + // Simulate a revoked refresh token by replacing the record with one + // the provider never issued. + let cache = crate::remote::token_cache::TokenCache::new( + &idp.config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let mut record = cache.load().await.unwrap().unwrap(); + record.refresh_token = "revoked-refresh".to_string(); + cache.store(&record).await.unwrap(); + + let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + let headers = second.get_headers().await.unwrap(); + assert_eq!(headers.get("authorization").unwrap(), "Bearer access-2"); + + assert_eq!(idp.invalid_grant_rejections.load(Ordering::SeqCst), 1); + assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 2); + + let record = cache.load().await.unwrap().unwrap(); + assert_eq!(record.refresh_token, "refresh-2"); + } + + #[tokio::test] + #[serial] + async fn test_session_login_status_logout_lifecycle() { + suppress_browser(); + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + + let session = OAuthSession::new(idp.config(dir.path())).unwrap(); + let status = session.status().await.unwrap(); + assert!(!status.refreshable); + + let status = session.login().await.unwrap(); + assert!(status.refreshable); + assert_eq!(status.issuer_url, idp.issuer_url); + assert_eq!(status.client_id, "client-id"); + assert_eq!(status.scopes, vec!["openid".to_string()]); + assert_eq!(status.flow, "device_code"); + assert!(status.obtained_at.is_some()); + + // An independent session manager sees the same cached login. + let other = OAuthSession::new(idp.config(dir.path())).unwrap(); + assert!(other.status().await.unwrap().refreshable); + + assert!(session.logout().await.unwrap().removed); + assert!(!session.logout().await.unwrap().removed); + assert!(!other.status().await.unwrap().refreshable); + assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + #[serial] + async fn test_login_without_refresh_token_clears_prior_record() { + suppress_browser(); + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + + let session = OAuthSession::new(idp.config(dir.path())).unwrap(); + session.login().await.unwrap(); + assert!(session.status().await.unwrap().refreshable); + + // A provider that stops issuing refresh tokens (for example a login + // without offline_access) must not leave the earlier account behind. + idp.issue_refresh_tokens.store(false, Ordering::SeqCst); + let status = session.login().await.unwrap(); + assert!(!status.refreshable); + assert!(!session.status().await.unwrap().refreshable); + } + + #[tokio::test] + async fn test_client_credentials_with_cache_stays_memory_only() { + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + + let mut config = idp.config(dir.path()); + config.flow = OAuthFlow::ClientCredentials; + config.client_secret = Some("secret".to_string()); + + let provider = OAuthHeaderProvider::new(config).unwrap(); + let headers = provider.get_headers().await.unwrap(); + assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1"); + assert_eq!(dir.path().read_dir().unwrap().count(), 0); + } + + #[test] + fn test_managed_identity_with_cache_is_rejected() { + let dir = cache_tempdir(); + let mut config = device_config(dir.path()); + config.flow = OAuthFlow::AzureManagedIdentity { client_id: None }; + + let err = OAuthHeaderProvider::new(config).unwrap_err(); + assert!( + matches!(err, Error::InvalidInput { message } if message.contains("AzureManagedIdentity")) + ); + } + + #[tokio::test] + #[serial] + async fn test_provider_debug_and_status_reveal_no_secrets() { + suppress_browser(); + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + + let provider = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap(); + let headers = provider.get_headers().await.unwrap(); + assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1"); + let debug = format!("{provider:?}"); + assert!(!debug.contains("access-1")); + assert!(!debug.contains("refresh-1")); + + let session = OAuthSession::new(idp.config(dir.path())).unwrap(); + let status = session.status().await.unwrap(); + assert!(!format!("{status:?}").contains("refresh-")); + } + + #[tokio::test] + async fn test_targeted_cache_refresh_and_logout_isolation() { + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + let mut config = idp.config(dir.path()); + let options = config.token_cache.clone().unwrap(); + let untargeted_key = CacheKey::new(&config).unwrap().file_stem; + assert_eq!( + untargeted_key, + hex_sha256( + format!( + "v1\n{}\nclient-id\nopenid\ndevice_code\npublic", + idp.issuer_url + ) + .as_bytes() + ) + ); + let mut sessions = Vec::new(); + let mut keys = std::collections::HashSet::new(); + for (resource, audience) in [ + (None, None), + (Some("urn:one"), None), + (None, Some("audience & ü")), + (Some("urn:one"), Some("audience & ü")), + (Some("urn:two"), Some("audience & ü")), + (Some("urn:one"), Some("other")), + ] { + config.resource = resource.map(str::to_owned); + config.audience = audience.map(str::to_owned); + let cache = TokenCache::new(&config, &options).unwrap(); + assert!(keys.insert(cache.key.file_stem.clone())); + let record = cache + .record_from_response(&TokenResponse { + access_token: AccessToken::new("unused".into()), + refresh_token: Some(RefreshToken::new("seed-refresh".into())), + expires_in: Some(3600), + token_type: None, + }) + .unwrap(); + cache.store(&record).await.unwrap(); + *idp.current_refresh.lock().unwrap() = Some("seed-refresh".into()); + let provider = OAuthHeaderProvider::new(config.clone()).unwrap(); + provider.get_headers().await.unwrap(); + let request = idp.requests.lock().unwrap().last().unwrap().clone(); + let params: std::collections::HashMap<_, _> = + url::form_urlencoded::parse(request.as_bytes()).collect(); + assert_eq!(params.get("resource").map(|s| s.as_ref()), resource); + assert_eq!(params.get("audience").map(|s| s.as_ref()), audience); + assert_eq!(params.get("grant_type").unwrap(), "refresh_token"); + let session = OAuthSession::new(config.clone()).unwrap(); + let status = session.status().await.unwrap(); + assert!(status.refreshable); + assert_eq!(status.resource, config.resource); + assert_eq!(status.audience, config.audience); + sessions.push(session); + } + assert!(sessions.pop().unwrap().logout().await.unwrap().removed); + for session in sessions { + assert!(session.status().await.unwrap().refreshable); + } + assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn test_legacy_cache_record_without_target_fields() { + let dir = cache_tempdir(); + let config = device_config(dir.path()); + let cache = TokenCache::new(&config, config.token_cache.as_ref().unwrap()).unwrap(); + let legacy = br#"{"version":1,"issuer_url":"https://issuer.example.com","client_id":"client-id","scopes":["openid"],"flow":"device_code","client_auth":"public","refresh_token":"legacy","obtained_at":1}"#; + write_record(dir.path(), &cache.record_path(), legacy).unwrap(); + let session = OAuthSession::new(config).unwrap(); + let status = session.status().await.unwrap(); + assert!(status.refreshable); + assert_eq!(status.resource, None); + assert_eq!(status.audience, None); + assert!(session.logout().await.unwrap().removed); + } + + #[test] + fn test_cache_key_canonicalizes_scopes_and_issuer() { + let mut config = device_config(Path::new("/tmp/cache")); + config.issuer_url = "https://issuer.example.com/".to_string(); + config.scopes = vec!["b".to_string(), " a ".to_string(), "a".to_string()]; + let key = CacheKey::new(&config).unwrap(); + assert_eq!(key.issuer_url, "https://issuer.example.com"); + assert_eq!(key.scopes, vec!["a".to_string(), "b".to_string()]); + + config.issuer_url = "https://issuer.example.com".to_string(); + let canonical = CacheKey::new(&config).unwrap(); + assert_eq!(canonical.file_stem, key.file_stem); + } + + #[test] + fn test_cache_key_separates_identity_dimensions() { + let base = device_config(Path::new("/tmp/cache")); + let base_key = CacheKey::new(&base).unwrap(); + + let mut other = base.clone(); + other.client_id = "other-client".to_string(); + assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem); + + let mut other = base.clone(); + other.issuer_url = "https://other.example.com".to_string(); + assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem); + + let mut other = base.clone(); + other.scopes = vec!["profile".to_string()]; + assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem); + + let mut other = base.clone(); + other.flow = OAuthFlow::AuthorizationCode(Default::default()); + assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem); + + let mut other = base.clone(); + other.client_secret = Some("secret".to_string()); + assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem); + } + + #[test] + fn test_cache_key_contains_no_secret_material() { + let mut config = device_config(Path::new("/tmp/cache")); + config.client_secret = Some("super-secret-value".to_string()); + let key = CacheKey::new(&config).unwrap(); + assert!(!key.file_stem.contains("super-secret-value")); + assert_eq!(key.file_stem.len(), 64); + } + + #[test] + fn test_flow_key_rejects_non_persistent_flows() { + let mut config = device_config(Path::new("/tmp/cache")); + config.flow = OAuthFlow::ClientCredentials; + let err = CacheKey::new(&config).unwrap_err(); + assert!( + matches!(err, Error::InvalidInput { message } if message.contains("not supported")) + ); + + let mut config = device_config(Path::new("/tmp/cache")); + config.flow = OAuthFlow::AzureManagedIdentity { client_id: None }; + assert!(CacheKey::new(&config).is_err()); + } + + #[test] + fn test_token_cache_options_defaults() { + let options = TokenCacheOptions::new(); + assert!(options.cache_dir.is_none()); + assert!(options.lock_timeout_secs.is_none()); + assert_eq!(options.lock_timeout(), Duration::from_secs(30)); + + let options = options.cache_dir("/tmp/x").lock_timeout_secs(5); + assert_eq!(options.cache_dir.as_deref(), Some(Path::new("/tmp/x"))); + assert_eq!(options.lock_timeout(), Duration::from_secs(5)); + } + + #[test] + fn test_token_cache_options_rejects_empty_dir() { + let options = TokenCacheOptions::new().cache_dir(""); + assert!(matches!( + options.resolved_dir(), + Err(Error::InvalidInput { message }) if message.contains("must not be empty") + )); + } + + #[tokio::test] + async fn test_session_lifecycle_without_cache_entry() { + let dir = cache_tempdir(); + let session = OAuthSession::new(device_config(dir.path())).unwrap(); + + let status = session.status().await.unwrap(); + assert!(!status.refreshable); + assert_eq!(status.issuer_url, "https://issuer.example.com"); + assert_eq!(status.client_id, "client-id"); + assert_eq!(status.scopes, vec!["openid".to_string()]); + assert_eq!(status.flow, "device_code"); + assert_eq!(status.obtained_at, None); + + let logout = session.logout().await.unwrap(); + assert!(!logout.removed); + } + + #[tokio::test] + async fn test_record_round_trip_and_redaction() { + let dir = cache_tempdir(); + let cache = TokenCache::new( + &device_config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let response = TokenResponse { + access_token: AccessToken::new("access-token".to_string()), + refresh_token: Some(RefreshToken::new("refresh-token".to_string())), + expires_in: Some(3600), + token_type: Some(BasicTokenType::Bearer), + }; + let record = cache.record_from_response(&response).unwrap(); + let debug = format!("{record:?}"); + assert!(!debug.contains("refresh-token")); + assert!(debug.contains("")); + // No access-token material is persisted. + let json = serde_json::to_string(&record).unwrap(); + assert!(!json.contains("access-token")); + + cache.store(&record).await.unwrap(); + let loaded = cache.load().await.unwrap().unwrap(); + assert_eq!(loaded.refresh_token, "refresh-token"); + assert_eq!(loaded.version, CACHE_RECORD_VERSION); + + // The on-disk file must not be group/other readable and must not be a symlink target. + let path = cache.record_path(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0); + } + assert!(std::fs::symlink_metadata(&path).unwrap().is_file()); + + assert!(cache.delete().await.unwrap()); + assert!(cache.load().await.unwrap().is_none()); + assert!(!cache.delete().await.unwrap()); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_record_rejects_symlink() { + let dir = cache_tempdir(); + let cache = TokenCache::new( + &device_config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let record = cache + .record_from_response(&TokenResponse { + access_token: AccessToken::new("a".to_string()), + refresh_token: Some(RefreshToken::new("r".to_string())), + expires_in: None, + token_type: None, + }) + .unwrap(); + cache.store(&record).await.unwrap(); + let path = cache.record_path(); + let target = dir.path().join("evil.json"); + std::fs::write(&target, "{}").unwrap(); + std::fs::remove_file(&path).unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + + let err = cache.load().await.unwrap_err(); + assert!( + matches!(err, Error::InvalidInput { message } if message.contains("not a regular file")) + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_record_rejects_world_readable_file() { + let dir = cache_tempdir(); + let cache = TokenCache::new( + &device_config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let path = cache.record_path(); + std::fs::write(&path, "{}").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let err = cache.load().await.unwrap_err(); + assert!( + matches!(err, Error::InvalidInput { message } if message.contains("group or other")) + ); + } + + /// Write a record file that passes the permission hardening checks. + fn write_record_file(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + } + + #[tokio::test] + async fn test_record_rejects_unknown_version_and_corruption() { + let dir = cache_tempdir(); + let cache = TokenCache::new( + &device_config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let path = cache.record_path(); + + // A complete, well-formed record with an unknown schema version. + let record = cache + .record_from_response(&TokenResponse { + access_token: AccessToken::new("a".to_string()), + refresh_token: Some(RefreshToken::new("r".to_string())), + expires_in: None, + token_type: None, + }) + .unwrap(); + let mut json = serde_json::to_value(&record).unwrap(); + json["version"] = serde_json::json!(99); + write_record_file(&path, &json.to_string()); + let err = cache.load().await.unwrap_err(); + assert!( + matches!(err, Error::Runtime { message } if message.contains("unsupported version")) + ); + + write_record_file(&path, r#"{"version":1,"issuer_url":"x""#); + let err = cache.load().await.unwrap_err(); + assert!(matches!(err, Error::Runtime { message } if message.contains("corrupt"))); + + write_record_file(&path, ""); + assert!( + cache + .load() + .await + .unwrap_err() + .to_string() + .contains("corrupt") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_cache_dir_rejects_open_permissions() { + let dir = cache_tempdir(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let err = TokenCache::new( + &device_config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { message } if message.contains("chmod 700"))); + } + + #[tokio::test] + async fn test_lock_serializes_and_releases() { + let dir = cache_tempdir(); + let cache = TokenCache::new( + &device_config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let guard = cache.acquire_lock().await.unwrap(); + + let contender = { + let dir = dir.path().to_path_buf(); + let cache2 = TokenCache::new( + &device_config(&dir), + &TokenCacheOptions::new() + .cache_dir(&dir) + .lock_timeout_secs(1), + ) + .unwrap(); + tokio::time::timeout(Duration::from_millis(300), cache2.acquire_lock()).await + }; + assert!(contender.is_err(), "second acquire must block while held"); + drop(guard); + + let cache3 = TokenCache::new( + &device_config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), cache3.acquire_lock()) + .await + .expect("lock re-acquirable after release") + .unwrap(); + } + + #[test] + fn test_token_cache_for_config_gating() { + let dir = cache_tempdir(); + assert!( + token_cache_for_config(&device_config(dir.path())) + .unwrap() + .is_some() + ); + + let mut config = device_config(dir.path()); + config.token_cache = None; + assert!(token_cache_for_config(&config).unwrap().is_none()); + + let mut config = device_config(dir.path()); + config.flow = OAuthFlow::ClientCredentials; + assert!(token_cache_for_config(&config).unwrap().is_none()); + + let mut config = device_config(dir.path()); + config.flow = OAuthFlow::AzureManagedIdentity { client_id: None }; + let err = token_cache_for_config(&config).unwrap_err(); + assert!( + matches!(err, Error::InvalidInput { message } if message.contains("AzureManagedIdentity")) + ); + } + + #[test] + fn test_oauth_session_requires_cache_options() { + let mut config = device_config(Path::new("/tmp/cache")); + config.token_cache = None; + let err = OAuthSession::new(config).unwrap_err(); + assert!(matches!(err, Error::InvalidInput { message } if message.contains("token_cache"))); + } + + #[tokio::test] + async fn test_store_skips_responses_without_refresh_token() { + let dir = cache_tempdir(); + let cache = TokenCache::new( + &device_config(dir.path()), + &TokenCacheOptions::new().cache_dir(dir.path()), + ) + .unwrap(); + let response = TokenResponse { + access_token: AccessToken::new("access-token".to_string()), + refresh_token: None, + expires_in: Some(3600), + token_type: None, + }; + assert!(cache.record_from_response(&response).is_none()); + cache.store_if_refreshable(&response).await.unwrap(); + assert!(cache.load().await.unwrap().is_none()); + } + + #[test] + fn test_session_status_debug_has_no_secrets() { + let status = SessionStatus { + refreshable: true, + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + scopes: vec!["openid".to_string()], + resource: None, + audience: None, + flow: "device_code".to_string(), + obtained_at: Some(100), + }; + let debug = format!("{status:?}"); + assert!(!debug.contains("refresh-token")); + } +} diff --git a/rust/lancedb/src/secrets.rs b/rust/lancedb/src/secrets.rs new file mode 100644 index 000000000..c980e51ee --- /dev/null +++ b/rust/lancedb/src/secrets.rs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Named Secrets: database-scoped credentials a Function binds by name. +//! +//! Nothing here holds a credential. The verbs live on +//! [`crate::connection::Connection`], and none of them returns a value -- by +//! construction rather than by policy, so there is no code path that could. +//! +//! What a Secret is, how one is named, and how a Function binds one all live +//! here; [`crate::function`] holds the bindings a FunctionVersion records, the +//! way Sophon's Secret catalog and Function catalog divide the same two. + +use serde::de; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +/// What a database records about a Secret. Never its value. +/// +/// Returned by [`crate::connection::Connection::describe_secret`]. There is no +/// field for the credential and no method that could produce one. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct SecretInfo { + /// The Secret's database-scoped name. + pub name: String, + /// When the Secret was created, in milliseconds since the Unix epoch. + pub created_at_millis: i64, + /// When the Secret's value was last rotated, in milliseconds since the Unix + /// epoch. + /// + /// This is the only observable that a rotation landed: no API returns a + /// credential, so a caller confirms `alter_secret` took effect by watching + /// this move. + pub updated_at_millis: i64, +} + +/// Where a Secret lives, carried as its parts rather than as one string. +/// +/// Nothing here is parsed, so nothing can parse two ways. A joined id would +/// instead need a delimiter excluded from every name and segment, agreed on by +/// both sides. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct SecretReference { + pub name: String, + /// The namespace holding the Secret. Empty is the root, and is absent from + /// the wire rather than sent empty: a binding states a namespace only when + /// it has one. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub namespace_path: Vec, +} + +impl SecretReference { + /// A Secret in the root namespace. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + namespace_path: Vec::new(), + } + } + + /// A Secret in `namespace_path`. + pub fn in_namespace(name: impl Into, namespace_path: Vec) -> Self { + Self { + name: name.into(), + namespace_path, + } + } +} + +/// How a Secret reaches the Function that binds it. +/// +/// One list rather than a field per delivery mode, so a mode added later is a +/// variant and the per-Function rules -- how many Secrets a Function may bind, +/// which ones it needs -- stay answerable from one place. +/// +/// A mode this client does not know decodes rather than failing the whole +/// FunctionVersion, as [`PythonRuntimeSpec`] does for an unknown runtime. That +/// takes both halves: [`SecretBinding::Unrecognized`] gives the wire somewhere +/// to land, and `#[non_exhaustive]` denies callers an exhaustive match, so a +/// later mode arrives as a case they already had to handle. Its payload is +/// dropped -- the client does not proxy catalog values. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum SecretBinding { + /// Delivered as an environment variable, which the UDF's library already + /// reads. The variable is the delivery target; the Secret is what fills it. + Env { + variable: String, + /// Named `secret_ref` rather than `secret` because a Job payload is + /// scanned server-side for credential-shaped keys, and a key called + /// `secret` trips that guard whatever it actually holds. + secret_ref: SecretReference, + }, + /// A binding kind introduced by a newer server. + Unrecognized { kind: String }, +} + +impl SecretBinding { + /// The wire discriminator reported by Sophon. + pub fn kind(&self) -> &str { + match self { + Self::Env { .. } => "env", + Self::Unrecognized { kind } => kind, + } + } + + /// The environment variable this binding fills, or `None` for a kind that + /// does not deliver through one. + pub fn variable(&self) -> Option<&str> { + match self { + Self::Env { variable, .. } => Some(variable), + Self::Unrecognized { .. } => None, + } + } + + /// The Secret bound, or `None` for a kind this client cannot read. + pub fn secret(&self) -> Option<&SecretReference> { + match self { + Self::Env { secret_ref, .. } => Some(secret_ref), + Self::Unrecognized { .. } => None, + } + } +} + +#[derive(Deserialize)] +struct EnvSecretBindingWire { + variable: String, + secret_ref: SecretReference, +} + +impl<'de> Deserialize<'de> for SecretBinding { + fn deserialize>(deserializer: D) -> std::result::Result { + let value = Value::deserialize(deserializer)?; + let kind = value + .get("kind") + .ok_or_else(|| de::Error::missing_field("kind"))? + .as_str() + .ok_or_else(|| de::Error::custom("secret binding kind must be a string"))? + .to_string(); + match kind.as_str() { + "env" => { + let wire: EnvSecretBindingWire = + serde_json::from_value(value).map_err(de::Error::custom)?; + Ok(Self::Env { + variable: wire.variable, + secret_ref: wire.secret_ref, + }) + } + _ => Ok(Self::Unrecognized { kind }), + } + } +} + +impl Serialize for SecretBinding { + fn serialize(&self, serializer: S) -> std::result::Result { + #[derive(Serialize)] + struct EnvBindingRef<'a> { + kind: &'static str, + variable: &'a str, + secret_ref: &'a SecretReference, + } + + #[derive(Serialize)] + struct UnrecognizedBindingRef<'a> { + kind: &'a str, + } + + match self { + Self::Env { + variable, + secret_ref, + } => EnvBindingRef { + kind: "env", + variable, + secret_ref, + } + .serialize(serializer), + Self::Unrecognized { kind } => UnrecognizedBindingRef { kind }.serialize(serializer), + } + } +} diff --git a/rust/lancedb/src/sql.rs b/rust/lancedb/src/sql.rs new file mode 100644 index 000000000..7c040c51b --- /dev/null +++ b/rust/lancedb/src/sql.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Handles to SQL queries running on a remote database. + +use std::{fmt, sync::Arc}; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::{Result, arrow::SendableRecordBatchStream}; + +/// The externally visible lifecycle state of a submitted SQL query. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QueryStatus { + /// The server is still executing the query. + Running, + /// The server has made the complete result available. + Finished, + /// The server accepted cancellation but has not confirmed it yet. + Cancelling, + /// The server confirmed cancellation. + Cancelled, +} + +impl fmt::Display for QueryStatus { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Running => "running", + Self::Finished => "finished", + Self::Cancelling => "cancelling", + Self::Cancelled => "cancelled", + }) + } +} + +/// A point-in-time description of a submitted SQL query. +#[derive(Clone, Debug, PartialEq)] +pub struct QueryDescription { + /// The stable, connection-scoped identifier assigned when the query was submitted. + pub id: Uuid, + /// The server-visible lifecycle state. + pub status: QueryStatus, + /// Server-reported completion progress, when known. Values are in `[0.0, 1.0]`, + /// with `1.0` meaning complete. + pub progress: Option, + /// When the server may stop accepting this query's continuation token. + pub expires_at: Option>, +} + +#[async_trait] +pub(crate) trait QueryHandle: Send + Sync { + fn id(&self) -> Uuid; + async fn describe(&self) -> Result; + async fn reader(&self) -> Result; + async fn cancel(&self) -> Result<()>; +} + +/// A handle to a submitted SQL query. +/// +/// The handle can be inspected, opened as an Arrow reader, or cancelled. +/// Dropping it does not cancel the server-side query. +/// Identifier lookup is scoped to the connection that submitted the query and +/// is not a durable resume mechanism. +pub struct Query { + handle: Arc, +} + +impl std::fmt::Debug for Query { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Query") + .field("id", &self.id()) + .finish() + } +} + +impl Query { + #[cfg(feature = "remote")] + pub(crate) fn new(handle: Arc) -> Self { + Self { handle } + } + + /// Return the stable, connection-scoped identifier for this query. + pub fn id(&self) -> Uuid { + self.handle.id() + } + + /// Get a point-in-time description of the query. + pub async fn describe(&self) -> Result { + self.handle.describe().await + } + + /// Wait for the initial result stream and return its Arrow record batches. + /// + /// The stream can begin yielding partial results before query execution is + /// complete. It continues polling for newly available result endpoints + /// until the query finishes and all endpoints have been consumed. + /// + /// Results are single-consumer. Calling this method more than once on the + /// same handle returns an error. + pub async fn reader(&self) -> Result { + self.handle.reader().await + } + + /// Request cancellation of the query. + pub async fn cancel(&self) -> Result<()> { + self.handle.cancel().await + } +} + +#[cfg(test)] +mod tests { + use super::QueryStatus; + + #[test] + fn query_status_display_is_stable() { + assert_eq!(QueryStatus::Running.to_string(), "running"); + assert_eq!(QueryStatus::Finished.to_string(), "finished"); + assert_eq!(QueryStatus::Cancelling.to_string(), "cancelling"); + assert_eq!(QueryStatus::Cancelled.to_string(), "cancelled"); + } +} diff --git a/rust/lancedb/src/table.rs b/rust/lancedb/src/table.rs index 530888032..4c254fd19 100644 --- a/rust/lancedb/src/table.rs +++ b/rust/lancedb/src/table.rs @@ -75,6 +75,7 @@ mod create_index; pub mod datafusion; pub(crate) mod dataset; pub mod delete; +pub mod freshness; pub mod lsm_stats; pub mod merge; pub mod optimize; @@ -242,7 +243,7 @@ enum BadVectorHandling { /// An error is returned #[default] Error, - /// The offending row is droppped + /// The offending row is dropped Drop, /// The invalid/missing items are replaced by fill_value Fill(f32), @@ -564,6 +565,29 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { fn id(&self) -> &str; /// Get the arrow [Schema] of the table. async fn schema(&self) -> Result; + /// Read this table's materialized-view definition and incarnation. + #[doc(hidden)] + async fn materialized_view_info( + &self, + ) -> Result { + let schema = self.schema().await?; + crate::materialized_view::materialized_view_info_from_metadata( + self.name(), + schema.metadata(), + ) + } + /// Submit a materialized-view refresh. + #[doc(hidden)] + async fn refresh_materialized_view_async( + &self, + _full: bool, + _source_version: Option, + _expected_incarnation: Option<&str>, + ) -> Result> { + Err(Error::NotSupported { + message: "remote materialized-view refresh is not supported on this table type".into(), + }) + } /// Create a read-only handle pinned to the table's current active revision. /// /// The returned handle is independent from later refreshes or checkouts on @@ -597,6 +621,14 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { query: &AnyQuery, options: QueryExecutionOptions, ) -> Result; + /// Whether [`BaseTable::analyze_plan`] is provided by a remote service. + /// + /// Client-side query wrappers use this to preserve backend metrics and + /// distributed-analysis options instead of replacing them with a local plan. + #[doc(hidden)] + fn analyze_plan_is_remote(&self) -> bool { + false + } /// Add new records to the table. async fn add(&self, add: AddDataBuilder) -> Result; @@ -772,7 +804,8 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { message: "Function columns are supported only on LanceDB Cloud and Enterprise".into(), }) } - /// Fill a computed column's unfilled rows. + /// Fill a computed column's unfilled rows and recompute those whose + /// inputs changed. /// /// The default returns `NotSupported`; Lance-backed tables override it. async fn refresh_column(&self, _column: &str) -> Result { @@ -780,8 +813,8 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { message: "computed columns are supported only on local tables".into(), }) } - /// Fill a computed column's unfilled rows, returning a [`Job`] tracking - /// the operation. + /// Fill a computed column's unfilled rows and recompute those whose + /// inputs changed, returning a [`Job`] tracking the operation. async fn refresh_column_async( &self, _column: &str, @@ -1186,6 +1219,9 @@ impl Table { /// valid empty blobs contain empty byte strings. Prefer /// [`Self::fetch_blob_files`] for large selections. /// + /// `_rowid` values stay valid after compaction when the table has stable + /// row ids. + /// /// ``` /// use arrow_array::UInt64Array; /// use futures::TryStreamExt; @@ -1227,6 +1263,9 @@ impl Table { /// the requests. Null blobs produce null output slots; empty ranges on /// non-null blobs produce empty byte strings. /// + /// `_rowid` values stay valid after compaction when the table has stable + /// row ids. + /// /// ``` /// use lancedb::blob::BlobRangeRequest; /// @@ -1265,6 +1304,9 @@ impl Table { /// Same length and order as `row_ids`. Null rows are `None`. Bytes are not /// read from disk until a call to [`BlobFile::read`]. /// + /// `_rowid` values stay valid after compaction when the table has stable + /// row ids. + /// /// ``` /// # use lancedb::Table; /// # async fn lazy_read(table: &Table, row_ids: &[u64]) -> Result<(), Box> { @@ -1311,7 +1353,7 @@ impl Table { /// Note: if your condition is something like "some_id_column == 7" and /// you are updating many rows (with different ids) then you will get /// better performance with a single [`merge_insert`] call instead of - /// repeatedly calilng this method. + /// repeatedly calling this method. pub fn update(&self) -> UpdateBuilder { UpdateBuilder::new(self.inner.clone()) } @@ -1500,7 +1542,9 @@ impl Table { /// /// * `on` One or more columns to join on. This is how records from the /// source table and target table are matched. Typically this is some - /// kind of key or id column. + /// kind of key or id column. Several columns match on the composite + /// key: a source row updates a target row only when it agrees on every + /// one of them. /// /// # Examples /// @@ -1654,9 +1698,9 @@ impl Table { /// Offsets are useful for sampling as the set of all valid offsets is easily /// known in advance to be [0, len(table)). /// - /// No guarantees are made regarding the order in which results are returned. If you - /// desire an output order that matches the order of the given offsets, you will need - /// to add the row offset column to the output and align it yourself. + /// No guarantees are made regarding the order in which results are returned. + /// Repeated offsets produce repeated rows, which makes this method suitable for + /// sampling with replacement. /// /// Parameters /// ---------- @@ -1722,6 +1766,33 @@ impl Table { self.inner.optimize(action).await } + /// Prune versions committed before an absolute timestamp. + /// + /// This is an internal entry point for language bindings whose public API + /// accepts an absolute cleanup cutoff. + #[doc(hidden)] + pub async fn optimize_prune_before( + &self, + before_timestamp: chrono::DateTime, + delete_unverified: Option, + error_if_tagged_old_versions: Option, + ) -> Result { + let native = self.as_native().ok_or_else(|| Error::NotSupported { + message: "optimize is not supported on LanceDB cloud.".into(), + })?; + let prune = optimize::cleanup_old_versions_before( + native, + before_timestamp, + delete_unverified, + error_if_tagged_old_versions, + ) + .await?; + Ok(OptimizeStats { + compaction: None, + prune: Some(prune), + }) + } + /// Add new columns to the table, providing values to fill in. pub fn add_columns(&self) -> AddColumnsBuilder { AddColumnsBuilder::new(self.inner.clone()) @@ -1732,9 +1803,10 @@ impl Table { /// Declared with /// [`AddColumnsBuilder::computed`](add_columns::AddColumnsBuilder::computed), /// a column starts empty and gets its values here. Fragments appended - /// since the last refresh are filled by the next one; fragments already - /// filled are left as they are, so the call is idempotent and does not - /// observe a mutated input. + /// since the last refresh are filled by the next one, and fragments whose + /// inputs changed since they were computed are recomputed (see + /// [`freshness`](crate::table::freshness)); everything else is left as + /// it is. /// /// Local tables only: a remote refresh runs as a server job, through /// [`Table::refresh_column_async`]. @@ -2787,7 +2859,7 @@ impl NativeTable { namespace_client: Option>, pushdown_operations: HashSet, ) -> Result { - computed_columns::ensure_no_foreign_declarations(batches.arrow_schema().fields())?; + let batches = computed_columns::admit_create_source(batches)?; // Default params uses format v1. let params = params.unwrap_or(WriteParams { ..Default::default() @@ -2887,6 +2959,7 @@ impl NativeTable { pushdown_operations: HashSet, session: Option>, ) -> Result { + let batches = computed_columns::admit_create_source(batches)?; // Build table_id from namespace + name for the storage options provider let mut table_id = namespace.clone(); table_id.push(name.to_string()); @@ -5660,7 +5733,7 @@ mod tests { TableStatistics { num_rows: 250, num_indices: 0, - total_bytes: 8925, + total_bytes: 8969, fragment_stats: FragmentStatistics { num_fragments: 11, num_small_fragments: 11, @@ -5765,6 +5838,13 @@ mod tests { assert!(index_bytes > 0); assert_eq!(with_index, data_only + index_bytes); + // Release builds reject unstable overlay datasets unless explicitly opted in. + if !lance_table::feature_flags::can_read_dataset( + lance_table::feature_flags::FLAG_UNSTABLE_DATA_OVERLAY_FILES, + ) { + return; + } + // Commit an overlay file supplying new `foo` values for the first three // rows of fragment 0. There is no high-level API that writes overlays // yet, so write the overlay's data file and commit the `DataOverlay` diff --git a/rust/lancedb/src/table/add_columns.rs b/rust/lancedb/src/table/add_columns.rs index 1ac0c6b4f..635a99bea 100644 --- a/rust/lancedb/src/table/add_columns.rs +++ b/rust/lancedb/src/table/add_columns.rs @@ -60,10 +60,11 @@ impl AddColumnsBuilder { /// every fragment that has none -- including fragments appended since the /// last refresh. /// - /// Refresh does not revisit a fragment it has filled, so mutating an input - /// leaves the value computed at fill time; recomputing means dropping the - /// column and declaring it again. An input cannot be renamed, retyped or - /// dropped while a declaration reads it, since the expression names it. + /// A refresh also recomputes the rows of a fragment whose inputs changed + /// since it was computed (see [`freshness`](super::freshness)), so a + /// mutated input is reflected by the next refresh. An input cannot be + /// renamed, retyped or dropped while a declaration reads it, since the + /// expression names it. /// /// On LanceDB Cloud and Enterprise the expression is planned by the /// server, and the refresh runs as a server job -- see diff --git a/rust/lancedb/src/table/add_data.rs b/rust/lancedb/src/table/add_data.rs index 15ccf9f66..0bfec9d68 100644 --- a/rust/lancedb/src/table/add_data.rs +++ b/rust/lancedb/src/table/add_data.rs @@ -1104,4 +1104,220 @@ mod tests { } } } + + /// A batch whose json values are all null infers as `DataType::Null` (this is what + /// pyarrow produces for a one-row insert with no value). The column's lance.json + /// identity lives in the field metadata, so dropping it while casting used to make + /// lance-core reject the batch as a schema mismatch. + #[tokio::test] + async fn test_add_all_null_json_column() { + use arrow_array::{Array, cast::AsArray, new_null_array}; + + let table_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + lance_arrow::json::json_field("data", true), + ])); + + let db = connect("memory://").execute().await.unwrap(); + let table = db + .create_empty_table("json_nulls", table_schema) + .execute() + .await + .unwrap(); + + let null_batch = |ids: Vec| { + let len = ids.len(); + RecordBatch::try_new( + Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("data", DataType::Null, true), + ])), + vec![ + Arc::new(arrow_array::Int64Array::from(ids)), + new_null_array(&DataType::Null, len), + ], + ) + .unwrap() + }; + + // A single all-null row as the very first write, then again after real JSON has + // been written - both scenarios from the bug report. + table.add(null_batch(vec![1])).execute().await.unwrap(); + + let arrow_json_field = Field::new("data", DataType::Utf8, true).with_metadata( + std::collections::HashMap::from([( + lance_arrow::ARROW_EXT_NAME_KEY.to_string(), + lance_arrow::json::ARROW_JSON_EXT_NAME.to_string(), + )]), + ); + let populated = RecordBatch::try_new( + Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + arrow_json_field, + ])), + vec![ + Arc::new(arrow_array::Int64Array::from(vec![2])), + Arc::new(arrow_array::StringArray::from(vec![Some(r#"{"a": 1}"#)])), + ], + ) + .unwrap(); + table.add(populated).execute().await.unwrap(); + table.add(null_batch(vec![3])).execute().await.unwrap(); + + assert_eq!(table.count_rows(None).await.unwrap(), 3); + + let results: Vec = table + .query() + .execute() + .await + .unwrap() + .try_collect() + .await + .unwrap(); + let batch = arrow_select::concat::concat_batches(&results[0].schema(), &results).unwrap(); + let ids = batch + .column_by_name("id") + .unwrap() + .as_primitive::(); + let json_strs = batch.column_by_name("data").unwrap().as_string::(); + for row in 0..batch.num_rows() { + match ids.value(row) { + 2 => assert_eq!(json_strs.value(row), r#"{"a":1}"#), + _ => assert!(json_strs.is_null(row), "row {row} expected null"), + } + } + } + + /// JSON text with no arrow.json label - what pyarrow infers for a column of `str` - is + /// encoded as JSONB rather than stored verbatim, at the top level and inside a struct. + #[tokio::test] + async fn test_add_unlabelled_json_strings() { + use arrow_array::{Array, cast::AsArray}; + use arrow_schema::Fields; + + let table_schema = Arc::new(Schema::new(vec![ + lance_arrow::json::json_field("data", true), + Field::new( + "info", + DataType::Struct(vec![lance_arrow::json::json_field("value", true)].into()), + true, + ), + ])); + + let db = connect("memory://").execute().await.unwrap(); + let table = db + .create_empty_table("json_strings", table_schema) + .execute() + .await + .unwrap(); + + let nested_children: Fields = vec![Field::new("value", DataType::Utf8, true)].into(); + let input_schema = Arc::new(Schema::new(vec![ + Field::new("data", DataType::Utf8, true), + Field::new("info", DataType::Struct(nested_children.clone()), true), + ])); + let batch = RecordBatch::try_new( + input_schema, + vec![ + Arc::new(arrow_array::StringArray::from(vec![ + Some(r#"{"a": 1}"#), + None, + ])), + Arc::new(arrow_array::StructArray::new( + nested_children, + vec![Arc::new(arrow_array::StringArray::from(vec![ + Some(r#"{"b": 2}"#), + None, + ]))], + None, + )), + ], + ) + .unwrap(); + table.add(batch).execute().await.unwrap(); + + let results: Vec = table + .query() + .execute() + .await + .unwrap() + .try_collect() + .await + .unwrap(); + let batch = arrow_select::concat::concat_batches(&results[0].schema(), &results).unwrap(); + assert_eq!(batch.num_rows(), 2); + + let data = batch.column_by_name("data").unwrap().as_string::(); + assert_eq!(data.value(0), r#"{"a":1}"#); + assert!(data.is_null(1)); + + let nested = batch + .column_by_name("info") + .unwrap() + .as_struct() + .column_by_name("value") + .unwrap() + .as_string::(); + assert_eq!(nested.value(0), r#"{"b":2}"#); + assert!(nested.is_null(1)); + } + + /// A null struct row survives a cast of one of its children, even when that child is + /// non-nullable. Lance checks a non-nullable child for nulls without applying the + /// parent's validity, so rebuilding the struct must leave the children untouched. + #[tokio::test] + async fn test_add_null_struct_with_non_nullable_child() { + use arrow_array::{Array, cast::AsArray}; + use arrow_schema::Fields; + + let table_schema = Arc::new(Schema::new(vec![Field::new( + "s", + DataType::Struct(vec![Field::new("x", DataType::Int64, false)].into()), + true, + )])); + + let db = connect("memory://").execute().await.unwrap(); + let table = db + .create_empty_table("null_struct", table_schema) + .execute() + .await + .unwrap(); + + // Int32 rather than the table's Int64, so the struct goes through reconstruction. + let input_children: Fields = vec![Field::new("x", DataType::Int32, false)].into(); + let input_schema = Arc::new(Schema::new(vec![Field::new( + "s", + DataType::Struct(input_children.clone()), + true, + )])); + let batch = RecordBatch::try_new( + input_schema, + vec![Arc::new(arrow_array::StructArray::new( + input_children, + vec![Arc::new(arrow_array::Int32Array::from(vec![0, 6]))], + Some(arrow::buffer::NullBuffer::from(vec![false, true])), + ))], + ) + .unwrap(); + table.add(batch).execute().await.unwrap(); + + let results: Vec = table + .query() + .execute() + .await + .unwrap() + .try_collect() + .await + .unwrap(); + let batch = arrow_select::concat::concat_batches(&results[0].schema(), &results).unwrap(); + let s = batch.column_by_name("s").unwrap().as_struct(); + assert!(s.is_null(0)); + assert_eq!( + s.column_by_name("x") + .unwrap() + .as_primitive::() + .value(1), + 6 + ); + } } diff --git a/rust/lancedb/src/table/computed_columns.rs b/rust/lancedb/src/table/computed_columns.rs index 4e8d8211e..f87d42426 100644 --- a/rust/lancedb/src/table/computed_columns.rs +++ b/rust/lancedb/src/table/computed_columns.rs @@ -21,6 +21,7 @@ //! [`computed_columns`] and [`computed_column_from_field`] read declarations //! back off a schema. +use futures::StreamExt; use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; @@ -29,14 +30,16 @@ use datafusion_common::{ScalarValue, tree_node::TreeNode}; use datafusion_expr::Expr; use datafusion_physical_plan::PhysicalExpr; use lance::dataset::NewColumnTransform; -use lance_arrow::FieldExt; -use lance_core::datatypes::{BLOB_V2_DESC_FIELD, format_field_path_minimal, parse_field_path}; +use lance_arrow::{ARROW_EXT_NAME_KEY, BLOB_V2_EXT_NAME, FieldExt}; +use lance_core::datatypes::{ + BLOB_V2_DESC_FIELD, BlobV2Layout, format_field_path_minimal, parse_field_path, +}; use lance_datafusion::planner::Planner; use lance_namespace::models::{JsonArrowDataType, JsonArrowField, JsonArrowSchema}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::function::{FunctionApplication, FunctionBinding}; +use crate::function::{FUNCTION_BLOB_V2_TYPE, FunctionApplication, FunctionBinding}; use crate::utils::resolve_arrow_field_path; use crate::{Error, Result}; @@ -58,12 +61,32 @@ pub const FUNCTION_BINDING_ID_META_KEY: &str = "computed_column.function.binding /// Field metadata key holding this sibling's ordered Function output ordinal. pub const FUNCTION_OUTPUT_ORDINAL_META_KEY: &str = "computed_column.function.output_ordinal"; +/// Reserved Function output ordinal for an internal flattened-result +/// assignment column. +pub const FUNCTION_ASSIGNMENT_OUTPUT_ORDINAL: u32 = u32::MAX; + /// Schema metadata key holding all immutable Function bindings. pub const FUNCTION_BINDINGS_META_KEY: &str = "lancedb::function_bindings"; /// Version of the schema-level Function binding envelope. pub const FUNCTION_BINDINGS_VERSION: u32 = 1; +/// Field metadata key holding `{fragment id -> input signature}` as JSON, +/// recorded by the refresh that last computed each fragment. Outside the +/// declaration namespace on purpose: a declaration is immutable through +/// metadata edits, this is rewritten by every refresh. Seeded empty at +/// declaration, so a column is tracked from birth; a column without it was +/// declared before signatures existed. +pub const SOURCE_SIGNATURE_META_KEY: &str = "computed_refresh.source_signature"; + +/// Field metadata key holding the definition digest a column was last +/// computed under. A change to it makes every row stale. +pub const DEFINITION_VERSION_META_KEY: &str = "computed_refresh.definition_version"; + +/// Field metadata key holding the table version the signature map describes: +/// where a refresh starts following compactions to carry freshness forward. +pub const RECORDED_AT_VERSION_META_KEY: &str = "computed_refresh.recorded_at_version"; + /// Value of [`KIND_META_KEY`] for a column defined by a SQL expression. pub const SQL_KIND: &str = "sql"; @@ -132,6 +155,7 @@ fn computed_column_metadata(expression: &str, inputs: &[String]) -> HashMap Result binding_id ), })?; - let output = binding - .outputs() - .get(output_ordinal as usize) - .ok_or_else(|| Error::InvalidInput { - message: format!( - "Function output '{}' has invalid ordinal {}", - field.name(), - output_ordinal - ), - })?; - if output.output_name != field.name().as_str() { + let destination = if output_ordinal == FUNCTION_ASSIGNMENT_OUTPUT_ORDINAL { + binding + .assignment() + .map(|assignment| assignment.output_name.as_str()) + } else { + binding + .outputs() + .get(output_ordinal as usize) + .map(|output| output.output_name.as_str()) + } + .ok_or_else(|| Error::InvalidInput { + message: format!( + "Function output '{}' has invalid ordinal {}", + field.name(), + output_ordinal + ), + })?; + if destination != field.name().as_str() { return Err(Error::InvalidInput { message: format!( "Function output '{}' does not match binding destination '{}'", field.name(), - output.output_name + destination ), }); } @@ -496,6 +528,7 @@ fn ensure_known_binding_shape(value: &Value) -> Result<()> { "function", "inputs", "outputs", + "assignment", "input_schema", "output_schema", ], @@ -506,7 +539,13 @@ fn ensure_known_binding_shape(value: &Value) -> Result<()> { object .get("function") .ok_or_else(|| invalid_function("Function binding is missing its exact version"))?, - &["name", "version"], + &[ + "name", + "object_id", + "location", + "version", + "manifest_digest", + ], "version reference", )?; for input in object @@ -544,10 +583,22 @@ fn ensure_known_binding_shape(value: &Value) -> Result<()> { "output mapping", )?; } + if let Some(assignment) = object.get("assignment") { + reject_unknown_object_fields( + assignment, + &["output_name", "output_field_id"], + "assignment mapping", + )?; + } Ok(()) } -fn resolve_field_path<'a>(schema: &'a ArrowSchema, path: &str) -> Result<&'a ArrowField> { +struct ResolvedFieldPath<'a> { + root: &'a ArrowField, + leaf: &'a ArrowField, +} + +fn resolve_field_path<'a>(schema: &'a ArrowSchema, path: &str) -> Result> { let parts = lance_core::datatypes::parse_field_path(path).map_err(|e| { invalid_function(format!("invalid Function input field path '{path}': {e}")) })?; @@ -556,25 +607,40 @@ fn resolve_field_path<'a>(schema: &'a ArrowSchema, path: &str) -> Result<&'a Arr "Function input field path cannot be empty", )); }; - let mut field = schema + let root = schema .field_with_name(root) .map_err(|_| invalid_function(format!("unknown Function input column '{path}'")))?; + let mut leaf = root; for child in children { - let DataType::Struct(fields) = field.data_type() else { + let DataType::Struct(fields) = leaf.data_type() else { return Err(invalid_function(format!( "Function input field path '{path}' traverses a non-struct field" ))); }; - field = fields + leaf = fields .iter() .find(|field| field.name() == child) .map(AsRef::as_ref) .ok_or_else(|| invalid_function(format!("unknown Function input column '{path}'")))?; } - Ok(field) + Ok(ResolvedFieldPath { root, leaf }) } fn canonical_input_arrow_type(field: &JsonArrowField) -> Result { + let is_blob_v2 = field + .metadata + .as_ref() + .and_then(|metadata| metadata.get(ARROW_EXT_NAME_KEY)) + .map(String::as_str) + == Some(BLOB_V2_EXT_NAME); + if is_blob_v2 || field.r#type.fields.is_some() { + let arrow_field = lance_namespace::schema::convert_json_arrow_field(field) + .map_err(|e| invalid_function(format!("invalid Function input field: {e}")))?; + validate_function_blob_nesting(&arrow_field, false)?; + if is_blob_v2 { + return Ok(FUNCTION_BLOB_V2_TYPE.to_string()); + } + } if field.r#type.fields.is_none() && field.r#type.length.is_none() { Ok(field.r#type.r#type.clone()) } else { @@ -584,6 +650,42 @@ fn canonical_input_arrow_type(field: &JsonArrowField) -> Result { } } +fn has_supported_blob_v2_layout(field: &ArrowField) -> bool { + field.is_blob_v2() + && matches!( + field.data_type(), + DataType::Struct(fields) if BlobV2Layout::classify(fields).is_some() + ) +} + +fn validate_function_blob_nesting(field: &ArrowField, inside_collection: bool) -> Result<()> { + if field.is_blob_v2() { + if inside_collection { + return Err(invalid_function(format!( + "Function field '{}' nests Blob v2 under a collection, which Function signatures do not support", + field.name() + ))); + } + if !has_supported_blob_v2_layout(field) { + return Err(invalid_function(format!( + "Function field '{}' has an invalid Blob v2 storage layout", + field.name() + ))); + } + return Ok(()); + } + match field.data_type() { + DataType::Struct(fields) => fields + .iter() + .try_for_each(|field| validate_function_blob_nesting(field, inside_collection)), + DataType::List(field) + | DataType::LargeList(field) + | DataType::FixedSizeList(field, _) + | DataType::Map(field, _) => validate_function_blob_nesting(field, true), + _ => Ok(()), + } +} + /// `fixed_size_list` -> (`item`, `size`); the comma must sit outside /// any nested `<...>`. fn split_fixed_size_list(raw: &str) -> Option<(&str, i32)> { @@ -663,10 +765,87 @@ fn parse_output_arrow_type(raw: &str) -> Result { Ok(data_type) } +fn function_output_field(name: &str, nullable: bool, raw: &str) -> Result { + let field = if raw == FUNCTION_BLOB_V2_TYPE { + lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![crate::blob( + name, nullable, + )])) + .map_err(|e| invalid_function(format!("could not encode Blob v2 output field: {e}")))? + .fields + .into_iter() + .next() + .ok_or_else(|| invalid_function("Blob v2 output field is missing"))? + } else { + JsonArrowField::new(name.to_string(), nullable, parse_output_arrow_type(raw)?) + }; + let arrow_field = lance_namespace::schema::convert_json_arrow_field(&field) + .map_err(|e| invalid_function(format!("invalid Function output field: {e}")))?; + validate_function_blob_nesting(&arrow_field, false)?; + Ok(field) +} + +/// Whether two fields describe the same Function output. +/// +/// `compare_identity` covers the field's own name and nullability. Struct +/// children carry both as part of the declaration and compare with it on. List +/// children do not: Lance rewrites a list item's name and nullability when it +/// writes, so a stored `fixed_size_list` comes back as +/// `fixed_size_list` and never matches the declaration again. +/// Comparing those by type alone keeps this agreeing with the server, which +/// draws the same distinction and is what accepted the column when it was +/// declared. +fn function_output_field_matches( + expected: &ArrowField, + actual: &ArrowField, + compare_identity: bool, +) -> bool { + if compare_identity + && (expected.name() != actual.name() || expected.is_nullable() != actual.is_nullable()) + { + return false; + } + match (expected.is_blob_v2(), actual.is_blob_v2()) { + (false, false) => function_output_type_matches(expected.data_type(), actual.data_type()), + (true, true) => { + has_supported_blob_v2_layout(expected) && has_supported_blob_v2_layout(actual) + } + _ => false, + } +} + +fn function_output_type_matches(expected: &DataType, actual: &DataType) -> bool { + if expected == actual { + return true; + } + match (expected, actual) { + (DataType::Struct(expected), DataType::Struct(actual)) => { + expected.len() == actual.len() + && expected + .iter() + .zip(actual) + .all(|(expected, actual)| function_output_field_matches(expected, actual, true)) + } + (DataType::List(expected), DataType::List(actual)) + | (DataType::LargeList(expected), DataType::LargeList(actual)) => { + function_output_field_matches(expected, actual, false) + } + ( + DataType::FixedSizeList(expected, expected_size), + DataType::FixedSizeList(actual, actual_size), + ) => expected_size == actual_size && function_output_field_matches(expected, actual, false), + (DataType::Map(expected, expected_sorted), DataType::Map(actual, actual_sorted)) => { + expected_sorted == actual_sorted + && function_output_field_matches(expected, actual, true) + } + _ => false, + } +} + fn ensure_binding_matches_schema(schema: &ArrowSchema, binding: &FunctionBinding) -> Result<()> { let mut input_fields = Vec::with_capacity(binding.inputs().len()); for input in binding.inputs() { - let field = resolve_field_path(schema, &input.field_path)?; + let resolved = resolve_field_path(schema, &input.field_path)?; + let field = resolved.leaf; if field .metadata() .get(COMPUTED_COLUMN_META_KEY) @@ -721,6 +900,11 @@ fn ensure_binding_matches_schema(schema: &ArrowSchema, binding: &FunctionBinding ))); } + let expected_inputs = binding + .inputs() + .iter() + .map(|input| input.field_path.clone()) + .collect::>(); let mut output_fields = Vec::with_capacity(binding.outputs().len()); for output in binding.outputs() { let field = schema.field_with_name(&output.output_name).map_err(|_| { @@ -730,32 +914,117 @@ fn ensure_binding_matches_schema(schema: &ArrowSchema, binding: &FunctionBinding output.output_name )) })?; - if field.name() != &output.output_name || !field.is_nullable() || output.nullable { + if field.name() != &output.output_name || !field.is_nullable() { return Err(invalid_function(format!( "Function output '{}' no longer matches binding '{}'", output.output_name, binding.binding_id() ))); } - let expected_type = parse_output_arrow_type(&output.arrow_type)?; - let expected_type = lance_namespace::schema::convert_json_arrow_type(&expected_type) - .map_err(|e| invalid_function(format!("invalid Function output type: {e}")))?; - if field.data_type() != &expected_type { + let type_matches = if output.arrow_type == FUNCTION_BLOB_V2_TYPE { + has_supported_blob_v2_layout(field) + } else { + let expected_type = parse_output_arrow_type(&output.arrow_type)?; + let expected_type = lance_namespace::schema::convert_json_arrow_type(&expected_type) + .map_err(|e| invalid_function(format!("invalid Function output type: {e}")))?; + function_output_type_matches(&expected_type, field.data_type()) + }; + if !type_matches { return Err(invalid_function(format!( "Function output '{}' type no longer matches binding '{}'", output.output_name, binding.binding_id() ))); } - output_fields.push(ArrowField::new( - field.name().clone(), - field.data_type().clone(), + let metadata = field.metadata(); + let declared_inputs = metadata + .get(INPUTS_META_KEY) + .and_then(|raw| serde_json::from_str::>(raw).ok()); + if metadata.get(COMPUTED_COLUMN_META_KEY).map(String::as_str) != Some("true") + || metadata.get(KIND_META_KEY).map(String::as_str) != Some(FUNCTION_KIND) + || metadata + .get(FUNCTION_BINDING_ID_META_KEY) + .map(String::as_str) + != Some(binding.binding_id()) + || metadata + .get(FUNCTION_OUTPUT_ORDINAL_META_KEY) + .and_then(|value| value.parse::().ok()) + != Some(output.output_ordinal) + || declared_inputs.as_deref() != Some(expected_inputs.as_slice()) + { + return Err(invalid_function(format!( + "Function output '{}' declaration metadata does not match binding '{}'", + output.output_name, + binding.binding_id() + ))); + } + // Rebuild from the declaration rather than from the stored field. The + // stored field carries Lance's write-time normalization, which would + // never round-trip back to the schema the binding recorded -- the same + // reason list children compare by type above. Whether the column on + // disk still matches is settled by that comparison, not here. + output_fields.push(function_output_field( + field.name(), true, - )); + &output.arrow_type, + )?); } - let output_schema = - lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(output_fields)) - .map_err(|e| invalid_function(format!("invalid Function output schema: {e}")))?; + if let Some(assignment) = binding.assignment() { + if binding + .outputs() + .iter() + .any(|output| output.result_field == WHOLE_RESULT_FIELD) + { + return Err(invalid_function(format!( + "Function binding '{}' cannot attach an assignment column to a whole result", + binding.binding_id() + ))); + } + let field = schema + .field_with_name(&assignment.output_name) + .map_err(|_| { + invalid_function(format!( + "Function binding '{}' assignment column '{}' is missing", + binding.binding_id(), + assignment.output_name + )) + })?; + let metadata = field.metadata(); + if field.data_type() != &DataType::Boolean + || !field.is_nullable() + || metadata.get(COMPUTED_COLUMN_META_KEY).map(String::as_str) != Some("true") + || metadata.get(KIND_META_KEY).map(String::as_str) != Some(FUNCTION_KIND) + || metadata + .get(FUNCTION_BINDING_ID_META_KEY) + .map(String::as_str) + != Some(binding.binding_id()) + || metadata + .get(FUNCTION_OUTPUT_ORDINAL_META_KEY) + .and_then(|value| value.parse::().ok()) + != Some(FUNCTION_ASSIGNMENT_OUTPUT_ORDINAL) + { + return Err(invalid_function(format!( + "Function binding '{}' assignment column no longer matches its declaration", + binding.binding_id() + ))); + } + let json = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ + ArrowField::new(assignment.output_name.clone(), DataType::Boolean, true), + ])) + .map_err(|e| invalid_function(format!("invalid Function assignment schema: {e}")))?; + output_fields.push(json.fields.into_iter().next().unwrap()); + } else if binding.outputs().iter().all(|output| output.nullable) + && binding + .outputs() + .iter() + .all(|output| output.result_field != WHOLE_RESULT_FIELD) + { + return Err(invalid_function(format!( + "Function binding '{}' has no flattened-result assignment column", + binding.binding_id() + ))); + } + let output_schema = JsonArrowSchema::new(output_fields); let output_schema = serde_json::to_value(output_schema).map_err(|e| { invalid_function(format!( "could not encode exact Function output schema: {e}" @@ -778,7 +1047,7 @@ pub(crate) fn plan_function_application( application: &FunctionApplication, output_name: Option<&str>, ) -> Result { - ensure_no_function_bindings_for_mutation(schema, "Function binding declaration")?; + ensure_supported_function_metadata(schema)?; if application.has_unknown_fields() { return Err(Error::NotSupported { message: "Function application contains fields from a newer contract".into(), @@ -828,8 +1097,9 @@ pub(crate) fn plan_function_application( input.parameter )) })?; - let field = resolve_field_path(schema, path)?; - if field + let resolved = resolve_field_path(schema, path)?; + if resolved + .root .metadata() .get(COMPUTED_COLUMN_META_KEY) .map(String::as_str) @@ -839,6 +1109,7 @@ pub(crate) fn plan_function_application( "Function input '{path}' is computed; computed-on-computed bindings are not supported" ))); } + let field = resolved.leaf; let parameter_field = ArrowField::new( input.parameter.clone(), field.data_type().clone(), @@ -882,16 +1153,15 @@ pub(crate) fn plan_function_application( "Function logical outputs must be non-nullable during NULL assignment", )); } - let data_type = - parse_output_arrow_type(output.arrow_type.as_deref().ok_or_else(|| { - invalid_function("scalar Function output is missing its Arrow type") - })?)?; + let arrow_type = output.arrow_type.as_deref().ok_or_else(|| { + invalid_function("scalar Function output is missing its Arrow type") + })?; outputs.push(FunctionOutputTarget { result_field: WHOLE_RESULT_FIELD.to_string(), output_name: name.to_string(), output_ordinal: 0, }); - output_fields.push(JsonArrowField::new(name.to_string(), true, data_type)); + output_fields.push(function_output_field(name, true, arrow_type)?); } "named_struct" => { if output.fields.is_empty() { @@ -909,11 +1179,6 @@ pub(crate) fn plan_function_application( "named-struct Function result field names must be unique", )); } - if output.fields.iter().any(|field| field.nullable) { - return Err(invalid_function( - "Function logical outputs must be non-nullable during NULL assignment", - )); - } let unknown = application .columns() .keys() @@ -936,11 +1201,7 @@ pub(crate) fn plan_function_application( .fields .iter() .map(|field| { - Ok(JsonArrowField::new( - field.name.clone(), - false, - parse_output_arrow_type(&field.arrow_type)?, - )) + function_output_field(&field.name, field.nullable, &field.arrow_type) }) .collect::>>()?; let mut data_type = JsonArrowDataType::new("struct".to_string()); @@ -968,11 +1229,7 @@ pub(crate) fn plan_function_application( output_name: name.clone(), output_ordinal: ordinal as u32, }); - output_fields.push(JsonArrowField::new( - name.clone(), - true, - parse_output_arrow_type(&field.arrow_type)?, - )); + output_fields.push(function_output_field(name, true, &field.arrow_type)?); } } } @@ -1062,7 +1319,8 @@ pub(crate) fn ensure_not_an_input(schema: &SchemaRef, paths: &[&str]) -> Result< } /// Reject a write that supplies values for a computed column directly: -/// only refresh materializes one, and refresh never revisits a filled row. +/// only refresh materializes one, and only refresh decides what it +/// recomputes. pub(crate) fn ensure_not_written<'a>( schema: &ArrowSchema, written: impl IntoIterator, @@ -1106,6 +1364,106 @@ pub(crate) fn ensure_batch_writes_no_computed_values( Ok(()) } +/// Validate every computed-column declaration `schema` carries against the +/// schema itself: every field with declaration metadata is a complete +/// declaration, a SQL declaration re-plans to the field it declares, a +/// Function declaration satisfies the binding contract, and no declaration +/// reads another computed column. What passes here is what `refresh_column` +/// can execute. +pub(crate) fn ensure_declarations_are_planned(schema: &ArrowSchema) -> Result<()> { + let invalid = |message: String| Error::InvalidInput { message }; + // A field with any declaration key is a declaration; a partial one is + // not "no declaration", it is a broken one. + for field in schema.fields() { + if field.metadata().keys().any(|k| is_declaration_key(k)) + && computed_column_from_field(field).is_none() + { + return Err(invalid(format!( + "field '{}' carries an incomplete computed-column declaration", + field.name() + ))); + } + } + let declared: HashSet = computed_columns(schema) + .into_iter() + .map(|c| c.name) + .collect(); + for column in computed_columns(schema) { + let field = schema.field_with_name(&column.name)?; + if !field.is_nullable() { + return Err(invalid(format!( + "computed column '{}' must be nullable until a refresh fills it", + column.name + ))); + } + match &column.kind { + ComputedColumnKind::Sql { expression } => { + let others: Vec = schema + .fields() + .iter() + .filter(|f| f.name() != &column.name) + .map(|f| f.as_ref().clone()) + .collect(); + let bound = bind(Arc::new(ArrowSchema::new(others)), &column.name, expression)?; + if let Some(input) = bound.roots.iter().find(|r| declared.contains(*r)) { + return Err(invalid(format!( + "computed column '{}' reads computed column '{input}'", + column.name + ))); + } + if &bound.data_type != field.data_type() { + return Err(invalid(format!( + "computed column '{}' is declared as {} but its expression yields {}", + column.name, + field.data_type(), + bound.data_type + ))); + } + let mut declared_inputs = column.inputs.clone(); + declared_inputs.sort(); + if declared_inputs != bound.inputs { + return Err(invalid(format!( + "computed column '{}' declares inputs {:?} but its expression reads {:?}", + column.name, declared_inputs, bound.inputs + ))); + } + } + ComputedColumnKind::Function { binding_id, .. } => { + // The binding validator resolves each input's leaf; the + // no-computed-input rule is about the root it hangs from. + let bindings = function_bindings(schema)?; + let Some(binding) = bindings.iter().find(|b| b.binding_id() == binding_id) else { + continue; // reported by the binding validator below + }; + // Roots come from the canonical path parser: a quoted + // top-level name may itself contain a dot. + if let Some(input) = binding + .inputs() + .iter() + .filter_map(|input| resolve_field_path(schema, &input.field_path).ok()) + .map(|resolved| resolved.root.name().as_str()) + .find(|r| declared.contains(*r)) + { + return Err(invalid(format!( + "computed column '{}' reads computed column '{input}'", + column.name + ))); + } + } + ComputedColumnKind::Unrecognized { kind } => { + return Err(Error::NotSupported { + message: format!( + "computed column '{}' is defined by '{kind}', which this version \ + of lancedb cannot fill", + column.name + ), + }); + } + } + } + ensure_supported_function_metadata(schema) +} + /// Reject fields carrying declaration metadata that did not come through /// [`plan`]. One authority for creation, overwrite and raw transforms. pub(crate) fn ensure_no_foreign_declarations<'a>( @@ -1137,7 +1495,9 @@ fn ensure_no_foreign_declaration(field: &ArrowField) -> Result<()> { /// kind, the expression, the inputs -- would bypass that validation or move /// a binding out from under a refresh. Drop the column and declare it again. pub(crate) fn is_declaration_key(key: &str) -> bool { - key == COMPUTED_COLUMN_META_KEY || key.starts_with("computed_column.") + key == COMPUTED_COLUMN_META_KEY + || key.starts_with("computed_column.") + || key.starts_with("computed_refresh.") } /// Reject retyping a computed column itself. @@ -1462,9 +1822,7 @@ fn plan_declarations(schema: SchemaRef, columns: &[(String, String)]) -> Result< for (name, expression) in columns { if schema.field_with_name(name).is_ok() { - return Err(Error::ColumnAlreadyExists { - name: name.to_string(), - }); + return Err(Error::ColumnAlreadyExists { name: name.clone() }); } let bound = bind(schema.clone(), name, expression)?; @@ -1566,6 +1924,54 @@ pub(super) async fn add_foreign_kind(table: &crate::Table, name: &str, kind: &st .unwrap(); } +/// Admit a table's initial data: every declaration it carries is validated, +/// and the stream refuses any batch with values in a computed column, whose +/// values come from refresh alone. One boundary for every way a table is +/// created. +pub(crate) fn admit_create_source( + batches: S, +) -> Result> { + let schema = batches.arrow_schema(); + ensure_declarations_are_planned(&schema)?; + let declared = computed_columns(&schema) + .into_iter() + .map(|c| c.name) + .collect(); + Ok(UnfilledDeclarations { + inner: batches, + declared, + }) +} + +/// A write source whose computed columns must arrive unfilled. +pub(crate) struct UnfilledDeclarations { + inner: S, + declared: Vec, +} + +impl lance_datafusion::utils::StreamingWriteSource + for UnfilledDeclarations +{ + fn arrow_schema(&self) -> SchemaRef { + self.inner.arrow_schema() + } + + fn into_stream(self) -> datafusion_physical_plan::SendableRecordBatchStream { + if self.declared.is_empty() { + return self.inner.into_stream(); + } + let schema = self.inner.arrow_schema(); + let declared = self.declared; + let stream = self.inner.into_stream().map(move |batch| { + let batch = batch?; + ensure_batch_writes_no_computed_values(&declared, &batch) + .map_err(|e| datafusion_common::DataFusionError::External(Box::new(e)))?; + Ok(batch) + }); + Box::pin(datafusion_physical_plan::stream::RecordBatchStreamAdapter::new(schema, stream)) + } +} + #[cfg(test)] mod tests { /// The gate's reproducer: the validator applies the same schema-level @@ -1584,6 +1990,69 @@ mod tests { assert!(super::validate_declarations(schema, &declarations).is_err()); } + #[test] + fn list_children_match_by_type_but_struct_children_by_identity() { + use arrow_schema::Field as F; + + // Lance rewrites a list item's name and nullability on write, so the + // stored field is no longer identical to what was declared. Comparing + // those by type keeps a table with a vector output usable. + let declared = + DataType::FixedSizeList(Arc::new(F::new("item", DataType::Float32, false)), 4); + let stored = DataType::FixedSizeList(Arc::new(F::new("item", DataType::Float32, true)), 4); + assert!(super::function_output_type_matches(&declared, &stored)); + + let renamed = + DataType::FixedSizeList(Arc::new(F::new("element", DataType::Float32, true)), 4); + assert!(super::function_output_type_matches(&declared, &renamed)); + + // The dimension is still part of the declaration. + let resized = DataType::FixedSizeList(Arc::new(F::new("item", DataType::Float32, true)), 8); + assert!(!super::function_output_type_matches(&declared, &resized)); + + // Struct children keep comparing by name and nullability. + let struct_declared = + DataType::Struct(vec![F::new("changed", DataType::Boolean, false)].into()); + let struct_nullable = + DataType::Struct(vec![F::new("changed", DataType::Boolean, true)].into()); + let struct_renamed = + DataType::Struct(vec![F::new("altered", DataType::Boolean, false)].into()); + assert!(super::function_output_type_matches( + &struct_declared, + &struct_declared + )); + assert!(!super::function_output_type_matches( + &struct_declared, + &struct_nullable + )); + assert!(!super::function_output_type_matches( + &struct_declared, + &struct_renamed + )); + + // A list nested inside a struct gets the list rule. + let nested_declared = DataType::Struct( + vec![F::new( + "tokens", + DataType::List(Arc::new(F::new("item", DataType::Utf8, false))), + true, + )] + .into(), + ); + let nested_stored = DataType::Struct( + vec![F::new( + "tokens", + DataType::List(Arc::new(F::new("item", DataType::Utf8, true))), + true, + )] + .into(), + ); + assert!(super::function_output_type_matches( + &nested_declared, + &nested_stored + )); + } + #[test] fn output_arrow_type_grammar_matches_the_shared_golden() { let golden: serde_json::Value = serde_json::from_str(include_str!( @@ -1611,7 +2080,7 @@ mod tests { } use arrow_array::record_batch; - use arrow_schema::DataType; + use arrow_schema::{DataType, TimeUnit}; use futures::TryStreamExt; use lance::dataset::ColumnAlteration; @@ -2353,6 +2822,8 @@ mod tests { ); } + /// A create carries a declaration only if it re-plans completely; this + /// one lacks its inputs and is refused before its forged value matters. #[tokio::test] async fn test_create_table_cannot_inject_a_declaration() { let conn = connect("memory://").execute().await.unwrap(); @@ -2380,7 +2851,7 @@ mod tests { .await .unwrap_err(); assert!( - matches!(&err, Error::InvalidInput { message } if message.contains("computed()")), + matches!(&err, Error::InvalidInput { message } if message.contains("computed column 'doubled'")), "{err:?}" ); } @@ -2557,7 +3028,7 @@ mod tests { fn named_struct_application(columns: &str) -> FunctionApplication { FunctionApplication::from_json(&format!( r#"{{ - "function":{{"name":"text_features","version":"fv_exact"}}, + "function":{{"name":"text_features","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}}, "inputs":[ {{"parameter":"title","kind":"column","value":{{"path":"title"}}}}, {{"parameter":"body","kind":"column","value":{{"path":"body"}}}} @@ -2572,6 +3043,95 @@ mod tests { .unwrap() } + fn blob_application(output: &str) -> FunctionApplication { + FunctionApplication::from_json(&format!( + r#"{{ + "function":{{"name":"blob_features","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}}, + "inputs":[ + {{"parameter":"image","kind":"column","value":{{"path":"image"}}}} + ], + "output":{output} + }}"# + )) + .unwrap() + } + + fn exact_arrow_type(field: ArrowField) -> String { + let json = + lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![field])).unwrap(); + serde_json::to_string(json.fields[0].r#type.as_ref()).unwrap() + } + + fn single_input_application(path: &str) -> FunctionApplication { + FunctionApplication::from_json( + &serde_json::json!({ + "function": {"name": "inspect", "version": "1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, + "inputs": [{ + "parameter": "value", + "kind": "column", + "value": {"path": path} + }], + "output": {"kind": "scalar", "arrow_type": "int64", "nullable": false} + }) + .to_string(), + ) + .unwrap() + } + + fn binding_from_plan(plan: &FunctionDeclarationPlan) -> FunctionBinding { + let inputs = plan + .input_bindings + .iter() + .enumerate() + .map(|(index, input)| { + serde_json::json!({ + "parameter": input.parameter, + "field_id": index, + "field_path": input.field_path, + "arrow_type": input.arrow_type, + "nullable": input.nullable, + }) + }) + .collect::>(); + let outputs = plan + .outputs + .iter() + .zip(&plan.output_schema.fields) + .enumerate() + .map(|(index, (output, field))| { + serde_json::json!({ + "result_field": output.result_field, + "output_name": output.output_name, + "output_field_id": 100 + index, + "output_ordinal": output.output_ordinal, + "arrow_type": canonical_input_arrow_type(field).unwrap(), + "nullable": false, + }) + }) + .collect::>(); + FunctionBinding::from_json( + &serde_json::json!({ + "binding_id": "fb_blob", + "function": plan.application.function(), + "inputs": inputs, + "outputs": outputs, + "input_schema": plan.input_schema, + "output_schema": plan.output_schema, + }) + .to_string(), + ) + .unwrap() + } + + fn full_blob_field(name: &str, nullable: bool) -> ArrowField { + ArrowField::new( + name, + DataType::Struct(lance_core::datatypes::BLOB_V2_LOGICAL_FIELDS.clone()), + nullable, + ) + .with_metadata(crate::blob(name, nullable).metadata().clone()) + } + fn function_binding_schema(title_nullable: bool, body_nullable: bool) -> ArrowSchema { ArrowSchema::new(vec![ ArrowField::new("title", DataType::Utf8, title_nullable), @@ -2581,6 +3141,48 @@ mod tests { ]) } + fn valid_function_binding_schema( + title_nullable: bool, + body_nullable: bool, + binding: &FunctionBinding, + ) -> ArrowSchema { + let mut fields = function_binding_schema(title_nullable, body_nullable) + .fields() + .iter() + .map(|field| field.as_ref().clone()) + .collect::>(); + let inputs = binding + .inputs() + .iter() + .map(|input| input.field_path.clone()) + .collect::>(); + for output in binding.outputs() { + let index = fields + .iter() + .position(|field| field.name() == &output.output_name) + .unwrap(); + fields[index] = fields[index] + .clone() + .with_metadata(function_computed_column_metadata( + binding.binding_id(), + output.output_ordinal, + &inputs, + )); + } + if let Some(assignment) = binding.assignment() { + fields.push( + ArrowField::new(&assignment.output_name, DataType::Boolean, true).with_metadata( + function_computed_column_metadata( + binding.binding_id(), + FUNCTION_ASSIGNMENT_OUTPUT_ORDINAL, + &inputs, + ), + ), + ); + } + ArrowSchema::new(fields) + } + #[test] fn test_non_nullable_function_inputs_can_bind_to_nullable_parameters() { let binding = FunctionBinding::from_json(include_str!( @@ -2588,7 +3190,79 @@ mod tests { )) .unwrap(); - ensure_binding_matches_schema(&function_binding_schema(false, false), &binding).unwrap(); + ensure_binding_matches_schema( + &valid_function_binding_schema(false, false, &binding), + &binding, + ) + .unwrap(); + } + + #[test] + fn test_binding_preserves_all_nullable_outputs_with_an_assignment_column() { + let mut raw_binding: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_function_binding.json" + )) + .unwrap(); + raw_binding["outputs"][0]["nullable"] = Value::Bool(true); + raw_binding["outputs"][1]["nullable"] = Value::Bool(true); + let without_assignment: FunctionBinding = + serde_json::from_value(raw_binding.clone()).unwrap(); + let error = ensure_binding_matches_schema( + &valid_function_binding_schema(true, true, &without_assignment), + &without_assignment, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("flattened-result assignment column") + ); + + raw_binding["assignment"] = serde_json::json!({ + "output_name": "__function_assignment_fb_01K3TEXT", + "output_field_id": -1, + }); + raw_binding["output_schema"]["fields"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "name": "__function_assignment_fb_01K3TEXT", + "nullable": true, + "type": {"type": "bool"}, + })); + let binding: FunctionBinding = serde_json::from_value(raw_binding).unwrap(); + ensure_binding_matches_schema( + &valid_function_binding_schema(true, true, &binding), + &binding, + ) + .unwrap(); + + let schema = ArrowSchema::new_with_metadata( + valid_function_binding_schema(true, true, &binding) + .fields() + .to_vec(), + HashMap::from([( + FUNCTION_BINDINGS_META_KEY.to_string(), + function_bindings_metadata(std::slice::from_ref(&binding)).unwrap(), + )]), + ); + ensure_supported_function_metadata(&schema).unwrap(); + + let mut metadata: Value = + serde_json::from_str(schema.metadata().get(FUNCTION_BINDINGS_META_KEY).unwrap()) + .unwrap(); + metadata["bindings"][0]["assignment"]["future"] = Value::Bool(true); + let future_schema = ArrowSchema::new_with_metadata( + schema.fields().to_vec(), + HashMap::from([( + FUNCTION_BINDINGS_META_KEY.to_string(), + serde_json::to_string(&metadata).unwrap(), + )]), + ); + assert!(matches!( + ensure_supported_function_metadata(&future_schema), + Err(Error::NotSupported { .. }) + )); } #[test] @@ -2601,8 +3275,11 @@ mod tests { raw_binding["input_schema"]["fields"][0]["nullable"] = Value::Bool(false); let binding: FunctionBinding = serde_json::from_value(raw_binding).unwrap(); - let err = ensure_binding_matches_schema(&function_binding_schema(true, false), &binding) - .unwrap_err(); + let err = ensure_binding_matches_schema( + &valid_function_binding_schema(true, false, &binding), + &binding, + ) + .unwrap_err(); assert!( matches!(&err, Error::InvalidInput { message } if message.contains("input column 'title' is nullable") @@ -2613,6 +3290,73 @@ mod tests { ); } + #[test] + fn test_second_binding_rejects_outputs_without_reciprocal_metadata() { + let binding = FunctionBinding::from_json(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_function_binding.json" + )) + .unwrap(); + let schema = ArrowSchema::new_with_metadata( + function_binding_schema(true, true).fields().to_vec(), + HashMap::from([( + FUNCTION_BINDINGS_META_KEY.to_string(), + function_bindings_metadata(std::slice::from_ref(&binding)).unwrap(), + )]), + ); + + let err = plan_function_application( + &schema, + &named_struct_application( + r#"{"normalized_text":"secondary_text","token_count":"secondary_token_count"}"#, + ), + None, + ) + .unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } + if message.contains("declaration metadata") + && message.contains("fb_01K3TEXT")), + "{err:?}" + ); + } + + #[test] + fn test_persisted_nested_input_keeps_leaf_level_validation() { + let mut raw_binding: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/first_class_functions/v1/remote_function_binding.json" + )) + .unwrap(); + raw_binding["inputs"][0]["field_path"] = Value::String("title.value".to_string()); + let binding: FunctionBinding = serde_json::from_value(raw_binding).unwrap(); + let title = ArrowField::new( + "title", + DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), + true, + ) + .with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + (EXPRESSION_META_KEY.to_string(), "title".to_string()), + ])); + let mut fields = vec![title, ArrowField::new("body", DataType::Utf8, true)]; + fields.extend(binding.outputs().iter().map(|output| { + let data_type = match output.arrow_type.as_str() { + "utf8" => DataType::Utf8, + "int64" => DataType::Int64, + other => panic!("unexpected fixture output type {other}"), + }; + ArrowField::new(&output.output_name, data_type, true).with_metadata( + function_computed_column_metadata( + binding.binding_id(), + output.output_ordinal, + &["title.value".into(), "body".into()], + ), + ) + })); + + ensure_binding_matches_schema(&ArrowSchema::new(fields), &binding).unwrap(); + } + #[test] fn test_function_binding_metadata_survives_schema_round_trip() { let binding = FunctionBinding::from_json(include_str!( @@ -2661,9 +3405,36 @@ mod tests { output_ordinal: 1, } if binding_id == "fb_01K3TEXT" )); - let err = plan_function_application(&reopened, &named_struct_application("{}"), None) + let dependent_application = FunctionApplication::from_json( + r#"{ + "function":{"name":"dependent","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, + "inputs":[ + {"parameter":"text","kind":"column","value":{"path":"search_text"}} + ], + "output":{"kind":"scalar","arrow_type":"int64","nullable":false} + }"#, + ) + .unwrap(); + let err = plan_function_application(&reopened, &dependent_application, Some("dependent")) .unwrap_err(); - assert!(matches!(err, Error::NotSupported { .. })); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("computed-on-computed")) + ); + let plan = plan_function_application( + &reopened, + &named_struct_application( + r#"{"normalized_text":"secondary_text","token_count":"secondary_token_count"}"#, + ), + None, + ) + .unwrap(); + assert_eq!( + plan.outputs + .iter() + .map(|output| output.output_name.as_str()) + .collect::>(), + ["secondary_text", "secondary_token_count"] + ); } #[test] @@ -2713,6 +3484,366 @@ mod tests { ); } + #[test] + fn test_named_struct_plan_preserves_nullable_result_fields() { + let mut value = serde_json::to_value(named_struct_application("{}")).unwrap(); + value["output"]["fields"][0]["nullable"] = Value::Bool(true); + value["output"]["fields"][1]["nullable"] = Value::Bool(true); + let application = FunctionApplication::from_json(&value.to_string()).unwrap(); + + let expanded = + plan_function_application(&function_input_schema(), &application, None).unwrap(); + assert!( + expanded + .output_schema + .fields + .iter() + .all(|field| field.nullable) + ); + + let whole = + plan_function_application(&function_input_schema(), &application, Some("features")) + .unwrap(); + let fields = whole.output_schema.fields[0] + .r#type + .fields + .as_ref() + .unwrap(); + assert!(fields[0].nullable); + assert!(fields[1].nullable); + } + + #[test] + fn test_blob_function_plans_semantic_input_and_scalar_output() { + let schema = ArrowSchema::new(vec![crate::blob("image", false)]); + let application = + blob_application(r#"{"kind":"scalar","arrow_type":"blob_v2","nullable":false}"#); + let plan = plan_function_application(&schema, &application, Some("thumbnail")).unwrap(); + + assert_eq!(plan.input_bindings[0].arrow_type, FUNCTION_BLOB_V2_TYPE); + let input_schema = + lance_namespace::schema::convert_json_arrow_schema(&plan.input_schema).unwrap(); + assert!(input_schema.field(0).is_blob_v2()); + let output_schema = + lance_namespace::schema::convert_json_arrow_schema(&plan.output_schema).unwrap(); + assert!(output_schema.field(0).is_blob_v2()); + } + + #[test] + fn binding_accepts_a_lance_normalized_list_child() { + // The whole guard, not just the type helper: this also reaches the + // output-schema comparison at the end of ensure_binding_matches_schema, + // which used to rebuild the schema from the stored field and so failed + // on exactly the same normalization. + let input = ArrowField::new("value", DataType::Int64, false); + let application = FunctionApplication::from_json( + &serde_json::json!({ + "function": {"name": "embed", "version": "1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, + "inputs": [{ + "parameter": "value", + "kind": "column", + "value": {"path": "value"} + }], + "output": { + "kind": "scalar", + "arrow_type": "fixed_size_list", + "nullable": false + } + }) + .to_string(), + ) + .unwrap(); + let plan = plan_function_application( + &ArrowSchema::new(vec![input.clone()]), + &application, + Some("embedding"), + ) + .unwrap(); + let binding = binding_from_plan(&plan); + + // The declaration says the item is non-nullable; Lance rewrites it to + // nullable on write, so this is what the column looks like on disk. + let stored = DataType::FixedSizeList( + Arc::new(ArrowField::new("item", DataType::Float32, true)), + 4, + ); + let output = ArrowField::new("embedding", stored, true).with_metadata( + function_computed_column_metadata(binding.binding_id(), 0, &["value".into()]), + ); + + ensure_binding_matches_schema(&ArrowSchema::new(vec![input.clone(), output]), &binding) + .unwrap(); + + // A different element type is still a mismatch. + let wrong = ArrowField::new( + "embedding", + DataType::FixedSizeList( + Arc::new(ArrowField::new("item", DataType::Float64, true)), + 4, + ), + true, + ) + .with_metadata(function_computed_column_metadata( + binding.binding_id(), + 0, + &["value".into()], + )); + assert!( + ensure_binding_matches_schema(&ArrowSchema::new(vec![input, wrong]), &binding).is_err() + ); + } + + #[test] + fn test_blob_scalar_binding_accepts_full_logical_layout() { + let input = crate::blob("image", false); + let application = + blob_application(r#"{"kind":"scalar","arrow_type":"blob_v2","nullable":false}"#); + let plan = plan_function_application( + &ArrowSchema::new(vec![input.clone()]), + &application, + Some("thumbnail"), + ) + .unwrap(); + let binding = binding_from_plan(&plan); + let mut metadata = full_blob_field("thumbnail", true).metadata().clone(); + metadata.extend(function_computed_column_metadata( + binding.binding_id(), + 0, + &["image".into()], + )); + let output = full_blob_field("thumbnail", true).with_metadata(metadata); + + ensure_binding_matches_schema(&ArrowSchema::new(vec![input, output]), &binding).unwrap(); + } + + #[test] + fn test_blob_binding_rejects_marker_on_invalid_storage_layout() { + let input = crate::blob("image", false); + let application = + blob_application(r#"{"kind":"scalar","arrow_type":"blob_v2","nullable":false}"#); + let plan = plan_function_application( + &ArrowSchema::new(vec![input.clone()]), + &application, + Some("thumbnail"), + ) + .unwrap(); + let binding = binding_from_plan(&plan); + let malformed = ArrowField::new("thumbnail", DataType::Int64, true) + .with_metadata(crate::blob("thumbnail", true).metadata().clone()); + + ensure_binding_matches_schema(&ArrowSchema::new(vec![input, malformed]), &binding) + .unwrap_err(); + } + + #[test] + fn test_blob_input_rejects_marker_on_invalid_storage_layout() { + let malformed = ArrowField::new("image", DataType::Int64, false) + .with_metadata(crate::blob("image", false).metadata().clone()); + let application = + blob_application(r#"{"kind":"scalar","arrow_type":"blob_v2","nullable":false}"#); + + plan_function_application( + &ArrowSchema::new(vec![malformed]), + &application, + Some("thumbnail"), + ) + .unwrap_err(); + } + + #[test] + fn test_non_blob_input_does_not_require_json_round_trip() { + let json = lance_namespace::schema::arrow_schema_to_json(&ArrowSchema::new(vec![ + ArrowField::new("event_time", DataType::Time64(TimeUnit::Microsecond), false), + ])) + .unwrap(); + + assert_eq!( + canonical_input_arrow_type(&json.fields[0]).unwrap(), + "time64" + ); + } + + #[test] + fn test_blob_named_struct_plans_expanded_and_whole_outputs() { + let schema = ArrowSchema::new(vec![crate::blob("image", false)]); + let application = blob_application( + r#"{"kind":"named_struct","fields":[ + {"name":"thumbnail","arrow_type":"blob_v2","nullable":false}, + {"name":"width","arrow_type":"int32","nullable":false} + ]}"#, + ); + + let expanded = plan_function_application(&schema, &application, None).unwrap(); + let expanded_schema = + lance_namespace::schema::convert_json_arrow_schema(&expanded.output_schema).unwrap(); + assert!(expanded_schema.field(0).is_blob_v2()); + assert_eq!(expanded_schema.field(1).data_type(), &DataType::Int32); + + let whole = plan_function_application(&schema, &application, Some("analysis")).unwrap(); + let whole_schema = + lance_namespace::schema::convert_json_arrow_schema(&whole.output_schema).unwrap(); + let DataType::Struct(fields) = whole_schema.field(0).data_type() else { + panic!("whole Function output should be a struct"); + }; + assert!(fields[0].is_blob_v2()); + assert_eq!(fields[1].data_type(), &DataType::Int32); + } + + #[test] + fn test_struct_blob_input_preserves_exact_schema_and_nullability() { + let payload = ArrowField::new( + "payload", + DataType::Struct(Fields::from(vec![ + ArrowField::new("mime_type", DataType::Utf8, false), + ArrowField::new( + "nested", + DataType::Struct(Fields::from(vec![crate::blob("image", true)])), + true, + ), + ])), + true, + ); + let plan = plan_function_application( + &ArrowSchema::new(vec![payload]), + &single_input_application("payload"), + Some("size"), + ) + .unwrap(); + + let declared: JsonArrowDataType = + serde_json::from_str(&plan.input_bindings[0].arrow_type).unwrap(); + let DataType::Struct(fields) = + lance_namespace::schema::convert_json_arrow_type(&declared).unwrap() + else { + panic!("expected a struct Function input") + }; + assert!(fields[1].is_nullable()); + let DataType::Struct(nested) = fields[1].data_type() else { + panic!("expected a recursive struct Function input") + }; + assert!(nested[0].is_blob_v2()); + assert!(nested[0].is_nullable()); + + let exact = lance_namespace::schema::convert_json_arrow_schema(&plan.input_schema).unwrap(); + let DataType::Struct(fields) = exact.field(0).data_type() else { + panic!("expected exact input schema to retain the struct") + }; + let DataType::Struct(nested) = fields[1].data_type() else { + panic!("expected exact input schema to retain the nested struct") + }; + assert!(nested[0].is_blob_v2()); + } + + #[test] + fn test_recursive_blob_result_plans_one_whole_named_struct_column() { + let details_type = exact_arrow_type(ArrowField::new( + "details", + DataType::Struct(Fields::from(vec![crate::blob("image", true)])), + false, + )); + let application = FunctionApplication::from_json( + &serde_json::json!({ + "function": {"name": "inspect", "version": "1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, + "inputs": [], + "output": { + "kind": "named_struct", + "fields": [ + {"name": "mime_type", "arrow_type": "utf8", "nullable": false}, + {"name": "details", "arrow_type": details_type, "nullable": false} + ] + } + }) + .to_string(), + ) + .unwrap(); + let plan = plan_function_application(&ArrowSchema::empty(), &application, Some("payload")) + .unwrap(); + + assert_eq!(plan.outputs.len(), 1); + assert_eq!(plan.outputs[0].result_field, WHOLE_RESULT_FIELD); + let schema = + lance_namespace::schema::convert_json_arrow_schema(&plan.output_schema).unwrap(); + assert_eq!(schema.field(0).name(), "payload"); + let DataType::Struct(fields) = schema.field(0).data_type() else { + panic!("whole named result must be one struct column") + }; + assert_eq!( + fields.iter().map(|field| field.name()).collect::>(), + ["mime_type", "details"] + ); + let DataType::Struct(details) = fields[1].data_type() else { + panic!("expected recursive result struct") + }; + assert!(details[0].is_blob_v2()); + assert!(!fields.iter().any(|field| field.name() == "payload")); + } + + #[test] + fn test_blob_children_under_collections_are_rejected() { + let collections = vec![ + DataType::List(Arc::new(crate::blob("item", false))), + DataType::LargeList(Arc::new(crate::blob("item", false))), + DataType::FixedSizeList(Arc::new(crate::blob("item", false)), 2), + DataType::Map( + Arc::new(ArrowField::new( + "entries", + DataType::Struct(Fields::from(vec![ + ArrowField::new("key", DataType::Utf8, false), + crate::blob("value", false), + ])), + false, + )), + false, + ), + ]; + for data_type in collections { + let schema = ArrowSchema::new(vec![ArrowField::new("value", data_type, false)]); + let error = plan_function_application( + &schema, + &single_input_application("value"), + Some("size"), + ) + .unwrap_err(); + assert!( + error.to_string().contains("under a collection"), + "got: {error}" + ); + } + } + + #[test] + fn test_blob_whole_struct_binding_accepts_full_logical_layout() { + let input = crate::blob("image", false); + let application = blob_application( + r#"{"kind":"named_struct","fields":[ + {"name":"thumbnail","arrow_type":"blob_v2","nullable":false}, + {"name":"width","arrow_type":"int32","nullable":false} + ]}"#, + ); + let plan = plan_function_application( + &ArrowSchema::new(vec![input.clone()]), + &application, + Some("analysis"), + ) + .unwrap(); + let binding = binding_from_plan(&plan); + + let output = ArrowField::new( + "analysis", + DataType::Struct(Fields::from(vec![ + full_blob_field("thumbnail", false), + ArrowField::new("width", DataType::Int32, false), + ])), + true, + ) + .with_metadata(function_computed_column_metadata( + binding.binding_id(), + 0, + &["image".into()], + )); + ensure_binding_matches_schema(&ArrowSchema::new(vec![input, output]), &binding).unwrap(); + } + #[test] fn test_function_mapping_and_sibling_collisions_fail_before_request() { let unknown = named_struct_application(r#"{"missing":"renamed"}"#); @@ -2744,7 +3875,7 @@ mod tests { fn test_unknown_and_mixed_version_function_contracts_fail_closed() { let application = FunctionApplication::from_json( r#"{ - "function":{"name":"f","version":"fv"}, + "function":{"name":"f","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs":[{"parameter":"title","kind":"future_source","value":{"path":"title"}}], "output":{"kind":"scalar","arrow_type":"int64","nullable":false} }"#, @@ -2756,7 +3887,7 @@ mod tests { let future_application = FunctionApplication::from_json( r#"{ - "function":{"name":"f","version":"fv"}, + "function":{"name":"f","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs":[], "output":{"kind":"scalar","arrow_type":"int64","nullable":false}, "future_declaration":{"mode":"managed"} @@ -2770,7 +3901,7 @@ mod tests { let nested_future_application = FunctionApplication::from_json( r#"{ - "function":{"name":"f","version":"fv"}, + "function":{"name":"f","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs":[], "output":{"kind":"scalar","arrow_type":"int64","nullable":false,"assignment":"cell_flag"} }"#, @@ -2819,5 +3950,38 @@ mod tests { assert!( matches!(&err, Error::InvalidInput { message } if message.contains("computed-on-computed")) ); + + let nested_title = ArrowField::new( + "title", + DataType::Struct(vec![ArrowField::new("value", DataType::Utf8, true)].into()), + true, + ) + .with_metadata(HashMap::from([ + (COMPUTED_COLUMN_META_KEY.to_string(), "true".to_string()), + (KIND_META_KEY.to_string(), SQL_KIND.to_string()), + ( + EXPRESSION_META_KEY.to_string(), + "struct('value')".to_string(), + ), + ])); + let nested_schema = ArrowSchema::new(vec![nested_title, schema.field(1).as_ref().clone()]); + let nested_application = FunctionApplication::from_json( + r#"{ + "function":{"name":"text_features","version":"1","object_id":"fixture","location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, + "inputs":[ + {"parameter":"title","kind":"column","value":{"path":"title.value"}}, + {"parameter":"body","kind":"column","value":{"path":"body"}} + ], + "output":{"kind":"named_struct","fields":[ + {"name":"normalized_text","arrow_type":"utf8","nullable":false}, + {"name":"token_count","arrow_type":"int64","nullable":false} + ]} + }"#, + ) + .unwrap(); + let err = plan_function_application(&nested_schema, &nested_application, None).unwrap_err(); + assert!( + matches!(&err, Error::InvalidInput { message } if message.contains("computed-on-computed")) + ); } } diff --git a/rust/lancedb/src/table/create_index.rs b/rust/lancedb/src/table/create_index.rs index c7d6b5675..0a0d00da1 100644 --- a/rust/lancedb/src/table/create_index.rs +++ b/rust/lancedb/src/table/create_index.rs @@ -30,7 +30,7 @@ use crate::index::vector::{VectorIndex, suggested_num_sub_vectors}; use crate::utils::{ resolve_lance_fts_field_path, supported_bitmap_data_type, supported_btree_data_type, supported_fm_data_type, supported_fts_data_type, supported_label_list_data_type, - supported_vector_data_type, + supported_vector_data_type, supported_zonemap_data_type, }; use super::NativeTable; @@ -133,7 +133,7 @@ impl NativeTable { ), }); } - (resolved.canonical_path, resolved.field) + (resolved.canonical_path, resolved.terminal_field) } else { Self::resolve_index_field(dataset.schema(), &opts.columns[0])? }; @@ -259,6 +259,32 @@ impl NativeTable { BuiltinIndexType::Fm, ))) } + Index::ZoneMap(_) => { + Self::validate_index_type(field, "ZoneMap", supported_zonemap_data_type)?; + Ok(Box::new(ScalarIndexParams::for_builtin( + BuiltinIndexType::ZoneMap, + ))) + } + Index::NGram(_) => Ok(Box::new(ScalarIndexParams::for_builtin( + BuiltinIndexType::NGram, + ))), + Index::BloomFilter(params) => { + let params = serde_json::to_value(params).map_err(|e| Error::InvalidInput { + message: format!("failed to serialize index params: {e}"), + })?; + Ok(Box::new( + ScalarIndexParams::for_builtin(BuiltinIndexType::BloomFilter) + .with_params(¶ms), + )) + } + Index::RTree(params) => { + let params = serde_json::to_value(params).map_err(|e| Error::InvalidInput { + message: format!("failed to serialize index params: {e}"), + })?; + Ok(Box::new( + ScalarIndexParams::for_builtin(BuiltinIndexType::RTree).with_params(¶ms), + )) + } Index::FTS(fts_opts) => { Self::validate_index_type(field, "FTS", supported_fts_data_type)?; Ok(Box::new(fts_opts)) @@ -418,6 +444,10 @@ impl NativeTable { Index::Bitmap(_) => IndexType::Bitmap, Index::LabelList(_) => IndexType::LabelList, Index::Fm(_) => IndexType::Fm, + Index::ZoneMap(_) => IndexType::ZoneMap, + Index::NGram(_) => IndexType::NGram, + Index::BloomFilter(_) => IndexType::BloomFilter, + Index::RTree(_) => IndexType::RTree, Index::FTS(_) => IndexType::Inverted, Index::IvfFlat(_) | Index::IvfSq(_) @@ -432,6 +462,7 @@ impl NativeTable { #[cfg(test)] mod tests { + use lance::index::DatasetIndexExt; use std::sync::Arc; use std::time::Duration; @@ -439,7 +470,8 @@ mod tests { use arrow_array::record_batch; use arrow_array::{ Array, ArrayRef, BinaryArray, BooleanArray, FixedSizeListArray, Float32Array, Int32Array, - LargeBinaryArray, LargeStringArray, RecordBatch, StringArray, StructArray, + LargeBinaryArray, LargeStringArray, ListArray, RecordBatch, StringArray, StructArray, + UInt32Array, }; use arrow_data::ArrayDataBuilder; use arrow_schema::{DataType, Field, Schema}; @@ -450,7 +482,8 @@ mod tests { use crate::connection::ConnectBuilder; use crate::index::Index; use crate::index::scalar::{ - BTreeIndexBuilder, BitmapIndexBuilder, DocumentGranularity, FmIndexBuilder, FtsIndexBuilder, + BTreeIndexBuilder, BitmapIndexBuilder, DocumentGranularity, FmIndexBuilder, + FtsIndexBuilder, ZoneMapIndexBuilder, }; use crate::index::vector::{ IvfHnswFlatIndexBuilder, IvfHnswPqIndexBuilder, IvfHnswSqIndexBuilder, @@ -458,6 +491,7 @@ mod tests { use crate::query::{ExecutableQuery, QueryBase}; use crate::table::optimize::{CompactionOptions, OptimizeAction}; use lance_index::scalar::FullTextSearchQuery; + use lance_index::scalar::inverted::query::{FtsQuery, MatchQuery}; fn create_fixed_size_list( values: T, @@ -599,6 +633,80 @@ mod tests { assert!(invalid_granularity.is_err()); } + #[tokio::test] + async fn test_nested_list_fts_uses_deepest_document_coordinates() { + let conn = connect("memory://").execute().await.unwrap(); + let mut docs = ListBuilder::new(ListBuilder::new(StringBuilder::new())); + + docs.values().values().append_value("alpha"); + docs.values().values().append_value("beta"); + docs.values().append(true); + docs.values().values().append_value("gamma"); + docs.values().values().append_value("alpha delta"); + docs.values().append(true); + docs.append(true); + + docs.values().append(true); + docs.values().values().append_value("alpha"); + docs.values().append(true); + docs.append(true); + + let batch = RecordBatch::try_from_iter(vec![ + ("id", Arc::new(Int32Array::from(vec![0, 1])) as ArrayRef), + ("docs", Arc::new(docs.finish()) as ArrayRef), + ]) + .unwrap(); + let table = conn.create_table("nested", batch).execute().await.unwrap(); + + let job = table + .create_index( + &["docs"], + Index::FTS( + FtsIndexBuilder::default() + .document_granularity(DocumentGranularity::ListElement), + ), + ) + .execute_async() + .await + .unwrap(); + job.wait().await.unwrap(); + + let query = FullTextSearchQuery::new_query(FtsQuery::Match( + MatchQuery::new("alpha".to_string()) + .with_column(Some("docs".to_string())) + .with_document_granularity(DocumentGranularity::ListElement), + )); + let batches = table + .query() + .full_text_search(query) + .limit(10) + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + + let mut hits = Vec::new(); + for batch in batches { + let ids = batch["id"].as_any().downcast_ref::().unwrap(); + let coordinates = batch["_doc_index"] + .as_any() + .downcast_ref::() + .unwrap(); + for row in 0..batch.num_rows() { + let coordinate = coordinates.value(row); + let coordinate = coordinate.as_any().downcast_ref::().unwrap(); + hits.push((ids.value(row), coordinate.values().to_vec())); + } + } + hits.sort_unstable(); + assert_eq!( + hits, + vec![(0, vec![0, 0]), (0, vec![1, 1]), (1, vec![1, 0])] + ); + } + /// Concurrent waiters, and a wait issued after the job settled, all /// succeed once the build does. #[tokio::test] @@ -1121,6 +1229,307 @@ mod tests { assert_eq!(stats.distance_type, None); } + #[tokio::test] + async fn test_create_zonemap_index() { + let conn = connect("memory://").execute().await.unwrap(); + let batch = record_batch!(("i", Int32, [1, 2, 3, 4, 5])).unwrap(); + let table = conn + .create_table("zonemap_table", batch) + .execute() + .await + .unwrap(); + + table + .create_index(&["i"], Index::ZoneMap(ZoneMapIndexBuilder::default())) + .execute() + .await + .unwrap(); + table + .wait_for_index(&["i_idx"], Duration::from_millis(10)) + .await + .unwrap(); + + let index_configs = table.list_indices().await.unwrap(); + assert_eq!(index_configs.len(), 1); + let index = index_configs.into_iter().next().unwrap(); + assert_eq!(index.index_type, crate::index::IndexType::ZoneMap); + assert_eq!(index.columns, vec!["i".to_string()]); + + let count = table + .query() + .only_if("i >= 2 AND i < 5") + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap() + .iter() + .map(|b| b.num_rows()) + .sum::(); + assert_eq!(count, 3); + + let stats = table.index_stats("i_idx").await.unwrap().unwrap(); + assert_eq!(stats.num_indexed_rows, 5); + assert_eq!(stats.num_unindexed_rows, 0); + assert_eq!(stats.index_type, crate::index::IndexType::ZoneMap); + assert_eq!(stats.distance_type, None); + } + + #[tokio::test] + async fn test_create_zonemap_index_on_wider_scalar_types() { + let conn = connect("memory://").execute().await.unwrap(); + let schema = Arc::new(Schema::new(vec![ + Field::new("large_text", DataType::LargeUtf8, true), + Field::new("binary", DataType::Binary, true), + Field::new("large_binary", DataType::LargeBinary, true), + ])); + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(LargeStringArray::from(vec![ + Some("alpha"), + None, + Some("omega"), + ])) as ArrayRef, + Arc::new(BinaryArray::from(vec![ + Some(b"aa".as_slice()), + None, + Some(b"zz".as_slice()), + ])) as ArrayRef, + Arc::new(LargeBinaryArray::from(vec![ + Some(b"left".as_slice()), + None, + Some(b"right".as_slice()), + ])) as ArrayRef, + ], + ) + .unwrap(); + let table = conn + .create_table("zonemap_wider_scalar_table", batch) + .execute() + .await + .unwrap(); + + for column in ["large_text", "binary", "large_binary"] { + table + .create_index(&[column], Index::ZoneMap(ZoneMapIndexBuilder::default())) + .execute() + .await + .unwrap(); + let index_name = format!("{column}_idx"); + table + .wait_for_index(&[&index_name], Duration::from_millis(10)) + .await + .unwrap(); + } + + let index_configs = table.list_indices().await.unwrap(); + assert_eq!(index_configs.len(), 3); + for index in index_configs { + assert_eq!(index.index_type, crate::index::IndexType::ZoneMap); + } + + let null_count = table + .query() + .only_if("large_text IS NULL") + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap() + .iter() + .map(|b| b.num_rows()) + .sum::(); + assert_eq!(null_count, 1); + } + + #[tokio::test] + async fn test_create_builtin_scalar_index() { + for (index, index_type, predicate, invalid_column) in [ + ( + Index::NGram(Default::default()), + crate::index::IndexType::NGram, + "contains(text, 'abc')", + "id", + ), + ( + Index::BloomFilter(Default::default()), + crate::index::IndexType::BloomFilter, + "text = 'abc'", + "flag", + ), + ( + Index::BloomFilter( + crate::index::scalar::BloomFilterIndexBuilder::default() + .number_of_items(2) + .unwrap() + .probability(0.01) + .unwrap(), + ), + crate::index::IndexType::BloomFilter, + "text = 'abc'", + "flag", + ), + ] { + let conn = connect("memory://").execute().await.unwrap(); + let batch = record_batch!( + ("id", Int32, [1, 2, 3, 4]), + ("text", Utf8, [Some("abc"), Some("xyz"), None, Some("")]), + ("flag", Boolean, [true, false, true, false]) + ) + .unwrap(); + let table = conn + .create_table("scalar", batch.clone()) + .execute() + .await + .unwrap(); + table.add(batch).execute().await.unwrap(); + + table + .create_index(&["text"], index.clone()) + .name("text_search".into()) + .train(false) + .execute() + .await + .unwrap(); + assert_eq!( + table.list_indices().await.unwrap()[0].index_type, + index_type + ); + assert!( + table + .create_index(&["text"], index.clone()) + .name("text_search".into()) + .replace(false) + .execute() + .await + .is_err() + ); + table + .create_index(&["text"], index.clone()) + .name("text_search".into()) + .execute() + .await + .unwrap(); + + let stats = table.index_stats("text_search").await.unwrap().unwrap(); + assert_eq!(stats.index_type, index_type); + assert_eq!(stats.num_indexed_rows, 8); + if let Index::BloomFilter(params) = &index { + let expected = serde_json::to_value(params).unwrap(); + let dataset = table.as_native().unwrap().dataset.get().await.unwrap(); + let stats: serde_json::Value = + serde_json::from_str(&dataset.index_statistics("text_search").await.unwrap()) + .unwrap(); + for (key, value) in expected.as_object().unwrap() { + assert_eq!(&stats["indices"][0][key], value); + } + } + for (filter, expected_rows) in [(predicate, 2), ("text IS NULL", 2), ("text = ''", 2)] { + let query = table.query().only_if(filter); + let plan = query.explain_plan(false).await.unwrap(); + if filter == predicate { + assert!(plan.contains("ScalarIndexQuery"), "{plan}"); + } + let batches = query + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!( + batches.iter().map(RecordBatch::num_rows).sum::(), + expected_rows + ); + } + let error = table + .create_index(&[invalid_column], index) + .execute() + .await + .unwrap_err(); + assert!( + error.to_string().to_lowercase().contains( + if index_type == crate::index::IndexType::NGram { + "ngram" + } else { + "bloom" + } + ), + "{error}" + ); + } + } + + #[cfg(feature = "geo")] + #[tokio::test] + async fn test_create_rtree_index() { + let points = StructArray::new( + vec![ + Field::new("x", DataType::Float64, false), + Field::new("y", DataType::Float64, false), + ] + .into(), + vec![ + Arc::new(arrow_array::Float64Array::from(vec![0.0, 10.0, 0.0])), + Arc::new(arrow_array::Float64Array::from(vec![0.0, 10.0, 0.0])), + ], + Some(arrow_buffer::NullBuffer::from(vec![true, true, false])), + ); + let field = Field::new("geometry", points.data_type().clone(), true).with_metadata( + std::collections::HashMap::from([ + ("ARROW:extension:name".into(), "geoarrow.point".into()), + ("ARROW:extension:metadata".into(), "{}".into()), + ]), + ); + let batch = + RecordBatch::try_new(Arc::new(Schema::new(vec![field])), vec![Arc::new(points)]) + .unwrap(); + let conn = connect("memory://").execute().await.unwrap(); + let table = conn.create_table("spatial", batch).execute().await.unwrap(); + table + .create_index( + &["geometry"], + Index::RTree( + crate::index::scalar::RTreeIndexBuilder::default() + .page_size(2) + .unwrap(), + ), + ) + .name("spatial_idx".into()) + .execute() + .await + .unwrap(); + let indices = table.list_indices().await.unwrap(); + assert_eq!(indices.len(), 1); + assert_eq!(indices[0].index_type, crate::index::IndexType::RTree); + let stats = table.index_stats("spatial_idx").await.unwrap().unwrap(); + assert_eq!(stats.index_type, crate::index::IndexType::RTree); + assert_eq!(stats.num_indexed_rows, 3); + let dataset = table.as_native().unwrap().dataset.get().await.unwrap(); + let stats: serde_json::Value = + serde_json::from_str(&dataset.index_statistics("spatial_idx").await.unwrap()).unwrap(); + assert_eq!(stats["indices"][0]["page_size"], 2); + for predicate in [ + "ST_Intersects(geometry, ST_GeomFromText('POLYGON ((-1 -1, 1 -1, 1 1, -1 1, -1 -1))'))", + "geometry IS NULL", + ] { + let query = table.query().only_if(predicate); + let plan = query.explain_plan(false).await.unwrap(); + assert!(plan.contains("ScalarIndexQuery"), "{plan}"); + let batches = query + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::(), 1); + } + } + #[tokio::test] async fn test_create_index_nested_field_paths() { let tmp_dir = tempdir().unwrap(); diff --git a/rust/lancedb/src/table/datafusion/blob_coerce.rs b/rust/lancedb/src/table/datafusion/blob_coerce.rs index 0596e7a2d..3b79ec022 100644 --- a/rust/lancedb/src/table/datafusion/blob_coerce.rs +++ b/rust/lancedb/src/table/datafusion/blob_coerce.rs @@ -5,12 +5,17 @@ //! //! [`super::cast::cast_to_table_schema`] calls [`coerce_blob_expr`]. +use std::fmt; +use std::hash::{Hash, Hasher}; use std::sync::Arc; -use arrow_schema::{DataType, Field, FieldRef, Fields}; +use arrow_array::{Array, BooleanArray, RecordBatch}; +use arrow_schema::{DataType, Field, FieldRef, Fields, Schema}; +use arrow_select::nullif::nullif; use datafusion::functions::core::{get_field, named_struct}; use datafusion_common::ScalarValue; use datafusion_common::config::ConfigOptions; +use datafusion_expr::ColumnarValue; use datafusion_physical_expr::ScalarFunctionExpr; use datafusion_physical_expr::expressions::{CastExpr, Literal}; use datafusion_physical_plan::PhysicalExpr; @@ -133,16 +138,102 @@ pub(super) fn coerce_blob_expr( ns_args.push(value); } - let expr: Arc = Arc::new(ScalarFunctionExpr::new( + let built: Arc = Arc::new(ScalarFunctionExpr::new( &format!("named_struct({})", table_field.name()), named_struct(), ns_args, table_field.clone(), config.clone(), )); + + // `named_struct` always yields a valid struct, so a null input would land + // as a row that set neither `data` nor `uri` -- not an absent blob but a + // malformed one, which Lance rejects on write. + let expr: Arc = Arc::new(AbsentBlobIsNull { + source: input_expr, + built, + field: table_field.clone(), + }); Ok((expr, table_field.clone())) } +/// Carries the source column's nullity onto the struct built for it. +/// +/// This is its own expression rather than a `CASE` because the projection +/// takes its output field from `return_field`, and the generic implementation +/// rebuilds a bare field -- which would drop the `lance.blob.v2` extension +/// metadata and stop the column being recognised as a blob at all. +#[derive(Debug, Clone)] +struct AbsentBlobIsNull { + source: Arc, + built: Arc, + field: FieldRef, +} + +impl fmt::Display for AbsentBlobIsNull { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "absent_blob_is_null({}, {})", self.source, self.built) + } +} + +impl PartialEq for AbsentBlobIsNull { + fn eq(&self, other: &Self) -> bool { + self.source.eq(&other.source) && self.built.eq(&other.built) && self.field == other.field + } +} + +impl Eq for AbsentBlobIsNull {} + +impl Hash for AbsentBlobIsNull { + fn hash(&self, state: &mut H) { + self.source.hash(state); + self.built.hash(state); + self.field.hash(state); + } +} + +impl PhysicalExpr for AbsentBlobIsNull { + fn return_field(&self, _input_schema: &Schema) -> datafusion_common::Result { + Ok(self.field.clone()) + } + + fn nullable(&self, _input_schema: &Schema) -> datafusion_common::Result { + Ok(true) + } + + fn evaluate(&self, batch: &RecordBatch) -> datafusion_common::Result { + let rows = batch.num_rows(); + let built = self.built.evaluate(batch)?.into_array(rows)?; + let source = self.source.evaluate(batch)?.into_array(rows)?; + let Some(nulls) = source.logical_nulls() else { + return Ok(ColumnarValue::Array(built)); + }; + // `nullif` nulls the rows the mask marks true, which is where the + // source had no value. + let absent = BooleanArray::new(!nulls.inner(), None); + Ok(ColumnarValue::Array(nullif(built.as_ref(), &absent)?)) + } + + fn children(&self) -> Vec<&Arc> { + vec![&self.source, &self.built] + } + + fn with_new_children( + self: Arc, + children: Vec>, + ) -> datafusion_common::Result> { + Ok(Arc::new(Self { + source: children[0].clone(), + built: children[1].clone(), + field: self.field.clone(), + })) + } + + fn fmt_sql(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self}") + } +} + enum BlobInputShape<'a> { Bytes, String, @@ -313,6 +404,11 @@ mod tests { let data = image.column_by_name("data").unwrap(); assert!(!data.is_null(0)); assert!(data.is_null(1)); + // The row itself has to be null, not merely a struct whose children + // are. A present-but-empty struct set neither `data` nor `uri`, which + // Lance rejects as malformed rather than reading as an absent blob. + assert!(!image.is_null(0)); + assert!(image.is_null(1)); } #[tokio::test] diff --git a/rust/lancedb/src/table/datafusion/cast.rs b/rust/lancedb/src/table/datafusion/cast.rs index bc47caeb2..0df1cb4ba 100644 --- a/rust/lancedb/src/table/datafusion/cast.rs +++ b/rust/lancedb/src/table/datafusion/cast.rs @@ -1,20 +1,25 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use arrow_array::StructArray; +use arrow_array::cast::AsArray; use arrow_cast::can_cast_types; use arrow_schema::{DataType, Field, FieldRef, Fields, Schema}; use datafusion::functions::core::{get_field, named_struct}; -use datafusion_common::ScalarValue; use datafusion_common::config::ConfigOptions; +use datafusion_common::metadata::FieldMetadata; +use datafusion_common::{DataFusionError, Result as DFResult, ScalarValue}; +use datafusion_expr::{ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature, Volatility}; use datafusion_physical_expr::ScalarFunctionExpr; use datafusion_physical_expr::expressions::{CastExpr, Literal}; use datafusion_physical_plan::expressions::Column; use datafusion_physical_plan::projection::ProjectionExec; use datafusion_physical_plan::{ExecutionPlan, PhysicalExpr}; -use lance_arrow::FieldExt; -use lance_arrow::json::{is_arrow_json_field, is_json_field}; +use lance_arrow::json::{ARROW_JSON_EXT_NAME, has_json_fields, is_arrow_json_field, is_json_field}; +use lance_arrow::{ARROW_EXT_NAME_KEY, FieldExt}; use super::blob_coerce::coerce_blob_expr; use crate::{Error, Result}; @@ -67,18 +72,33 @@ fn build_field_exprs( let input_field = &input_fields[input_idx]; let input_expr = get_input_expr(input_idx); - // Special case: input is arrow.json (PyArrow pa.json_() extension type backed by - // Utf8/LargeUtf8) and the table field is lance.json (backed by LargeBinary). - // Lance-core's write path already handles the arrow.json → lance.json conversion - // (including JSONB encoding), so we pass the expression through unchanged and let - // lance-core deal with it. Attempting to cast Utf8 → LargeBinary here would - // produce a field whose metadata still identifies it as arrow.json, which then - // causes a schema-mismatch error inside lance-core. - if is_arrow_json_field(input_field) && is_json_field(table_field) { + // PyArrow's pa.json_() is already labelled arrow.json, which is what lance-core wants + // to see, so pass it straight through. + if is_json_field(table_field) && is_arrow_json_field(input_field) { result.push((input_expr, Arc::clone(input_field) as FieldRef)); continue; } + // Anything else destined for a json column needs its JSON leaves labelled; see + // `json_write_target`. Structs are excluded because the recursion below rebuilds them + // field by field, which also handles reordered and partial input. + if !matches!(table_field.data_type(), DataType::Struct(_)) + && let Some(target) = json_write_target(input_field, table_field) + && can_cast_types(input_field.data_type(), target.data_type()) + { + // The label goes on the cast's target field rather than the field returned + // alongside it, because DataFusion derives the projection's output schema from + // `PhysicalExpr::return_field`. + let target: FieldRef = Arc::new(target); + let expr = Arc::new(CastExpr::new_with_target_field( + input_expr, + target.clone(), + None, + )); + result.push((expr, target)); + continue; + } + // Blob columns accept raw binary on write; exact matches pass through below. if table_field.is_blob_v2() && input_field.as_ref() != table_field.as_ref() { result.push(coerce_blob_expr( @@ -90,6 +110,18 @@ fn build_field_exprs( continue; } + // A column whose values are all null infers as `Null` (pyarrow does this for a list of + // dicts), so there is no input type to cast from. Emit typed nulls carrying the table + // field verbatim: a plain cast would drop the field metadata, and extension columns + // such as lance.json are identified by that metadata alone, so lance-core would then + // reject the batch as a schema mismatch. + if matches!(input_field.data_type(), DataType::Null) + && !matches!(table_field.data_type(), DataType::Null) + { + result.push((null_literal(table_field)?, table_field.clone())); + continue; + } + let expr = match (input_field.data_type(), table_field.data_type()) { // Both are structs: recurse into sub-fields to handle subschemas and casts. (DataType::Struct(in_children), DataType::Struct(tbl_children)) @@ -137,7 +169,16 @@ fn build_field_exprs( config.clone(), )); - result.push((ns_expr, output_field)); + result.push(( + restore_struct_validity( + ns_expr, + input_expr, + input_field, + &output_field, + config.clone(), + ), + output_field, + )); continue; } // Types match: pass through. @@ -171,6 +212,193 @@ fn build_field_exprs( Ok(result) } +// `named_struct` returns a struct with no null bitmap, and the `get_field` calls feeding it +// read each child without applying the parent's validity, so a null input struct would come +// back non-null with its masked children exposed. Move the input's null buffer onto the +// rebuilt struct. +fn restore_struct_validity( + rebuilt: Arc, + input_expr: Arc, + input_field: &FieldRef, + output_field: &FieldRef, + config: Arc, +) -> Arc { + if !input_field.is_nullable() { + return rebuilt; + } + + Arc::new(ScalarFunctionExpr::new( + &format!("restore_validity({})", output_field.name()), + RESTORE_VALIDITY_UDF.clone(), + vec![rebuilt, input_expr], + output_field.clone(), + config, + )) +} + +static RESTORE_VALIDITY_UDF: LazyLock> = + LazyLock::new(|| Arc::new(datafusion_expr::ScalarUDF::from(RestoreValidityUdf::new()))); + +/// Returns its first argument, a struct, carrying the null buffer of its second. +/// +/// Selecting a typed null for the null rows instead would nullify their children too, which +/// Lance rejects outright for a non-nullable child, even where the parent masks it. Children +/// therefore have to survive the round trip byte for byte. +#[derive(Debug, Hash, PartialEq, Eq)] +struct RestoreValidityUdf { + signature: Signature, +} + +impl RestoreValidityUdf { + fn new() -> Self { + Self { + signature: Signature::any(2, Volatility::Immutable), + } + } +} + +impl ScalarUDFImpl for RestoreValidityUdf { + fn name(&self) -> &str { + "restore_validity" + } + + fn signature(&self) -> &Signature { + &self.signature + } + + fn return_type(&self, arg_types: &[DataType]) -> DFResult { + Ok(arg_types[0].clone()) + } + + fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DFResult { + let rows = args.number_rows; + let rebuilt = args.args[0].to_array(rows)?; + let nulls_from = args.args[1].to_array(rows)?; + + let rebuilt = rebuilt.as_struct_opt().ok_or_else(|| { + DataFusionError::Internal(format!( + "restore_validity expects a struct, got {}", + rebuilt.data_type() + )) + })?; + + let nulls = nulls_from.logical_nulls(); + let (fields, columns, _) = rebuilt.clone().into_parts(); + let restored = StructArray::try_new_with_length(fields, columns, nulls, rows)?; + Ok(ColumnarValue::Array(Arc::new(restored))) + } +} + +// The storage type arrow.json would use for `input`, or None if it cannot hold JSON text. +fn arrow_json_storage_type(input: &DataType) -> Option { + match input { + // arrow.json only recognises Utf8 and LargeUtf8 storage, so a view has to be cast. + DataType::Utf8 | DataType::Utf8View => Some(DataType::Utf8), + DataType::LargeUtf8 => Some(DataType::LargeUtf8), + _ => None, + } +} + +fn arrow_json_field(name: &str, storage: DataType, nullable: bool) -> Field { + Field::new(name, storage, nullable).with_metadata(HashMap::from([( + ARROW_EXT_NAME_KEY.to_string(), + ARROW_JSON_EXT_NAME.to_string(), + )])) +} + +/// Rewrite `table_field` so that every lance.json leaf the input supplies as text becomes an +/// arrow.json leaf, leaving the rest of the shape untouched. +/// +/// Lance-core encodes JSON text into JSONB on write, but only for leaves labelled arrow.json. +/// Casting to the lance.json storage type instead relabels raw text as JSONB, which appends +/// successfully but leaves the column unreadable, so the label has to reach every leaf however +/// deeply it is nested. Returns `None` when there is nothing to relabel. +fn json_write_target(input_field: &Field, table_field: &Field) -> Option { + if is_json_field(table_field) { + let storage = if is_arrow_json_field(input_field) { + input_field.data_type().clone() + } else { + arrow_json_storage_type(input_field.data_type())? + }; + return Some(arrow_json_field( + table_field.name(), + storage, + table_field.is_nullable(), + )); + } + + if !has_json_fields(table_field) { + return None; + } + + let relabelled = match (input_field.data_type(), table_field.data_type()) { + ( + DataType::List(input_item) + | DataType::LargeList(input_item) + | DataType::FixedSizeList(input_item, _), + DataType::List(table_item) + | DataType::LargeList(table_item) + | DataType::FixedSizeList(table_item, _), + ) => { + let item: FieldRef = Arc::new(json_write_target(input_item, table_item)?); + match table_field.data_type() { + DataType::List(_) => DataType::List(item), + DataType::LargeList(_) => DataType::LargeList(item), + DataType::FixedSizeList(_, len) => DataType::FixedSizeList(item, *len), + _ => unreachable!("matched a list type above"), + } + } + (DataType::Map(input_entries, _), DataType::Map(table_entries, sorted)) => { + let entries = json_write_target(input_entries, table_entries)?; + DataType::Map(Arc::new(entries), *sorted) + } + (DataType::Struct(input_children), DataType::Struct(table_children)) => { + let mut children = Vec::with_capacity(table_children.len()); + let mut relabelled_any = false; + for table_child in table_children { + let relabelled_child = input_children + .iter() + .find(|f| f.name() == table_child.name()) + .and_then(|input_child| json_write_target(input_child, table_child)); + match relabelled_child { + Some(child) => { + relabelled_any = true; + children.push(Arc::new(child)); + } + None => children.push(table_child.clone()), + } + } + if !relabelled_any { + return None; + } + DataType::Struct(children.into()) + } + _ => return None, + }; + + Some( + Field::new(table_field.name(), relabelled, table_field.is_nullable()) + .with_metadata(table_field.metadata().clone()), + ) +} + +// The field's metadata is attached to the literal itself, because DataFusion derives the +// projection's output schema from `PhysicalExpr::return_field` rather than from the field we +// return alongside the expression. +fn null_literal(field: &FieldRef) -> Result> { + let scalar = ScalarValue::try_new_null(field.data_type()).map_err(|e| Error::InvalidInput { + message: format!( + "cannot build null literal for column '{}' of type {}: {e}", + field.name(), + field.data_type() + ), + })?; + Ok(Arc::new(Literal::new_with_metadata( + scalar, + Some(FieldMetadata::from(field.as_ref())), + ))) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -715,4 +943,379 @@ mod tests { assert!(result.column(0).is_null(1)); assert_eq!(v2, r#"{"y": 2}"#); } + + /// Plain JSON text (what pyarrow infers for a column of `str`, and what a caller writing + /// JSON by hand supplies) has to be labelled arrow.json so lance-core encodes it as JSONB. + /// Casting it to the table field's LargeBinary storage type would store the raw text. + #[rstest::rstest] + #[case::utf8(DataType::Utf8, DataType::Utf8)] + #[case::large_utf8(DataType::LargeUtf8, DataType::LargeUtf8)] + #[case::utf8_view(DataType::Utf8View, DataType::Utf8)] + #[tokio::test] + async fn test_unlabelled_string_into_lance_json_gets_arrow_json_label( + #[case] input_type: DataType, + #[case] expected_type: DataType, + ) { + use lance_arrow::json::{is_arrow_json_field, json_field}; + + let table_schema = Schema::new(vec![json_field("data", true)]); + + let input_schema = Arc::new(Schema::new(vec![Field::new("data", input_type, true)])); + let values = vec![Some(r#"{"x": 1}"#), None]; + let input_array = arrow_cast::cast( + &StringArray::from(values) as &dyn arrow_array::Array, + input_schema.field(0).data_type(), + ) + .unwrap(); + let input_batch = RecordBatch::try_new(input_schema, vec![input_array]).unwrap(); + + let plan = plan_from_batch(input_batch).await; + let projected = cast_to_table_schema(plan, &table_schema).unwrap(); + + let out_field = projected.schema().field_with_name("data").unwrap().clone(); + assert_eq!(out_field.data_type(), &expected_type); + assert!( + is_arrow_json_field(&out_field), + "output field must be labelled arrow.json, got {:?}", + out_field.metadata() + ); + + let result = collect(projected).await; + assert_eq!(result.num_rows(), 2); + assert_eq!(result.column(0).null_count(), 1); + } + + /// A json leaf inside a list is relabelled too. The outer field is a list, so without + /// recursing into it the generic container cast would turn the text into LargeBinary + /// labelled lance.json - an append that succeeds but stores unreadable JSON. + #[rstest::rstest] + #[case::unlabelled(DataType::Utf8, false)] + #[case::already_labelled(DataType::Utf8, true)] + #[tokio::test] + async fn test_unlabelled_list_item_into_lance_json_gets_arrow_json_label( + #[case] item_type: DataType, + #[case] input_labelled: bool, + ) { + use lance_arrow::json::{is_arrow_json_field, json_field}; + + use super::arrow_json_field; + + let table_schema = Schema::new(vec![Field::new( + "docs", + DataType::List(Arc::new(json_field("item", true))), + true, + )]); + + let input_item = if input_labelled { + Arc::new(arrow_json_field("item", item_type, true)) + } else { + Arc::new(Field::new("item", item_type, true)) + }; + let input_schema = Arc::new(Schema::new(vec![Field::new( + "docs", + DataType::List(input_item.clone()), + true, + )])); + let values = StringArray::from(vec![Some(r#"{"k": 1}"#), Some(r#"{"k": 2}"#)]); + let input_batch = RecordBatch::try_new( + input_schema, + vec![Arc::new(ListArray::new( + input_item, + OffsetBuffer::new(vec![0, 1, 2].into()), + Arc::new(values), + None, + ))], + ) + .unwrap(); + + let plan = plan_from_batch(input_batch).await; + let projected = cast_to_table_schema(plan, &table_schema).unwrap(); + + let out_field = projected.schema().field_with_name("docs").unwrap().clone(); + let DataType::List(out_item) = out_field.data_type() else { + panic!("expected a list, got {}", out_field.data_type()); + }; + assert!( + is_arrow_json_field(out_item), + "the list item must be labelled arrow.json, got {out_item:?}" + ); + + let result = collect(projected).await; + assert_eq!(result.num_rows(), 2); + } + + /// The same, for a json column nested inside a struct: the struct is rebuilt from its + /// children, so the label has to travel on the child field. + #[tokio::test] + async fn test_unlabelled_struct_child_into_lance_json_gets_arrow_json_label() { + use lance_arrow::json::{is_arrow_json_field, json_field}; + + let table_schema = Schema::new(vec![Field::new( + "info", + DataType::Struct( + vec![ + Field::new("id", DataType::Int64, true), + json_field("value", true), + ] + .into(), + ), + true, + )]); + + let input_children: Fields = vec![ + Field::new("id", DataType::Int64, true), + Field::new("value", DataType::Utf8, true), + ] + .into(); + let input_schema = Arc::new(Schema::new(vec![Field::new( + "info", + DataType::Struct(input_children.clone()), + true, + )])); + let input_batch = RecordBatch::try_new( + input_schema, + vec![Arc::new(StructArray::new( + input_children, + vec![ + Arc::new(Int64Array::from(vec![1, 2])), + Arc::new(StringArray::from(vec![Some(r#"{"a": 1}"#), None])), + ], + None, + ))], + ) + .unwrap(); + + let plan = plan_from_batch(input_batch).await; + let projected = cast_to_table_schema(plan, &table_schema).unwrap(); + + let out_field = projected.schema().field_with_name("info").unwrap().clone(); + let DataType::Struct(out_children) = out_field.data_type() else { + panic!("expected a struct, got {}", out_field.data_type()); + }; + let value = out_children.iter().find(|f| f.name() == "value").unwrap(); + assert!( + is_arrow_json_field(value), + "nested field must be labelled arrow.json, got {:?}", + value.metadata() + ); + + let result = collect(projected).await; + let info: &StructArray = result.column(0).as_any().downcast_ref().unwrap(); + assert_eq!(info.column_by_name("value").unwrap().null_count(), 1); + } + + /// An all-null column comes through as `DataType::Null` (pyarrow infers that for a batch + /// of dicts whose values are all `None`). The lance.json extension metadata lives on the + /// field alone, so it has to be carried into the output schema or lance-core rejects the + /// batch with a "json vs large_binary" schema mismatch. + #[tokio::test] + async fn test_null_column_into_lance_json_keeps_extension_metadata() { + use lance_arrow::ARROW_EXT_NAME_KEY; + use lance_arrow::json::{JSON_EXT_NAME, json_field}; + + let table_schema = Schema::new(vec![ + Field::new("id", DataType::Int64, false), + json_field("data", true), + ]); + + let input_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("data", DataType::Null, true), + ])); + let input_batch = RecordBatch::try_new( + input_schema, + vec![ + Arc::new(Int64Array::from(vec![0, 1, 2])), + arrow_array::new_null_array(&DataType::Null, 3), + ], + ) + .unwrap(); + + let plan = plan_from_batch(input_batch).await; + let projected = cast_to_table_schema(plan, &table_schema).unwrap(); + + let out_field = projected.schema().field_with_name("data").unwrap().clone(); + assert_eq!(out_field.data_type(), &DataType::LargeBinary); + assert_eq!( + out_field + .metadata() + .get(ARROW_EXT_NAME_KEY) + .map(|s| s.as_str()), + Some(JSON_EXT_NAME), + "output field must still identify itself as lance.json" + ); + + let result = collect(projected).await; + assert_eq!(result.num_rows(), 3); + assert_eq!(result.column_by_name("data").unwrap().null_count(), 3); + } + + /// The same, for a lance.json column nested inside a struct: the struct is rebuilt from + /// its children, so each child field must keep its own metadata, and a null struct must + /// stay null even though it is rebuilt child by child. + #[tokio::test] + async fn test_null_struct_child_into_lance_json_keeps_extension_metadata() { + use lance_arrow::ARROW_EXT_NAME_KEY; + use lance_arrow::json::{JSON_EXT_NAME, json_field}; + + let table_schema = Schema::new(vec![Field::new( + "meta", + DataType::Struct( + vec![ + Field::new("id", DataType::Int64, true), + json_field("doc", true), + ] + .into(), + ), + true, + )]); + + let input_children: Fields = vec![ + Field::new("id", DataType::Int64, true), + Field::new("doc", DataType::Null, true), + ] + .into(); + let input_schema = Arc::new(Schema::new(vec![Field::new( + "meta", + DataType::Struct(input_children.clone()), + true, + )])); + let input_batch = RecordBatch::try_new( + input_schema, + vec![Arc::new(StructArray::new( + input_children, + vec![ + Arc::new(Int64Array::from(vec![7, 8])), + arrow_array::new_null_array(&DataType::Null, 2), + ], + Some(arrow::buffer::NullBuffer::from(vec![false, true])), + ))], + ) + .unwrap(); + + let plan = plan_from_batch(input_batch).await; + let projected = cast_to_table_schema(plan, &table_schema).unwrap(); + + let out_field = projected.schema().field_with_name("meta").unwrap().clone(); + let DataType::Struct(out_children) = out_field.data_type() else { + panic!("expected a struct, got {}", out_field.data_type()); + }; + let doc = out_children.iter().find(|f| f.name() == "doc").unwrap(); + assert_eq!(doc.data_type(), &DataType::LargeBinary); + assert_eq!( + doc.metadata().get(ARROW_EXT_NAME_KEY).map(|s| s.as_str()), + Some(JSON_EXT_NAME) + ); + + let result = collect(projected).await; + let meta: &StructArray = result.column(0).as_any().downcast_ref().unwrap(); + assert!(meta.is_null(0), "a null struct must stay null once rebuilt"); + assert!(meta.is_valid(1)); + assert_eq!(meta.column_by_name("doc").unwrap().null_count(), 2); + } + + /// Any struct whose children need adjusting is rebuilt child by child, so the parent's + /// nulls have to be restored afterwards - not only for the extension-column cases. + #[tokio::test] + async fn test_null_struct_stays_null_when_child_is_cast() { + let input_children: Fields = vec![Field::new("x", DataType::Int32, true)].into(); + let table_schema = Schema::new(vec![Field::new( + "s", + DataType::Struct(vec![Field::new("x", DataType::Int64, true)].into()), + true, + )]); + + let input_schema = Arc::new(Schema::new(vec![Field::new( + "s", + DataType::Struct(input_children.clone()), + true, + )])); + let input_batch = RecordBatch::try_new( + input_schema, + vec![Arc::new(StructArray::new( + input_children, + vec![Arc::new(Int32Array::from(vec![5, 6]))], + Some(arrow::buffer::NullBuffer::from(vec![false, true])), + ))], + ) + .unwrap(); + + let plan = plan_from_batch(input_batch).await; + let projected = cast_to_table_schema(plan, &table_schema).unwrap(); + + let result = collect(projected).await; + let s: &StructArray = result.column(0).as_any().downcast_ref().unwrap(); + assert!(s.is_null(0)); + assert!(s.is_valid(1)); + let x: &Int64Array = s.column(0).as_any().downcast_ref().unwrap(); + assert_eq!(x.value(1), 6); + } + + /// Lance rejects a non-nullable child that carries nulls even where the parent masks + /// them, so the null rows have to keep the placeholder children the input gave them. + #[tokio::test] + async fn test_null_struct_keeps_children_of_a_non_nullable_child() { + let input_children: Fields = vec![Field::new("x", DataType::Int32, false)].into(); + let table_schema = Schema::new(vec![Field::new( + "s", + DataType::Struct(vec![Field::new("x", DataType::Int64, false)].into()), + true, + )]); + + let input_schema = Arc::new(Schema::new(vec![Field::new( + "s", + DataType::Struct(input_children.clone()), + true, + )])); + let input_batch = RecordBatch::try_new( + input_schema, + vec![Arc::new(StructArray::new( + input_children, + vec![Arc::new(Int32Array::from(vec![0, 6]))], + Some(arrow::buffer::NullBuffer::from(vec![false, true])), + ))], + ) + .unwrap(); + + let plan = plan_from_batch(input_batch).await; + let projected = cast_to_table_schema(plan, &table_schema).unwrap(); + + let result = collect(projected).await; + let s: &StructArray = result.column(0).as_any().downcast_ref().unwrap(); + assert!(s.is_null(0)); + assert!(s.is_valid(1)); + let x: &Int64Array = s.column(0).as_any().downcast_ref().unwrap(); + assert_eq!(x.null_count(), 0); + assert_eq!(x.value(1), 6); + } + + /// A `Null` input column against a plain table column writes nulls too, including for + /// target types that a DataFusion cast would not reach. + #[tokio::test] + async fn test_null_column_into_struct_column() { + let children: Fields = vec![Field::new("x", DataType::Int32, true)].into(); + let table_schema = Schema::new(vec![Field::new( + "s", + DataType::Struct(children.clone()), + true, + )]); + + let input_schema = Arc::new(Schema::new(vec![Field::new("s", DataType::Null, true)])); + let input_batch = RecordBatch::try_new( + input_schema, + vec![arrow_array::new_null_array(&DataType::Null, 2)], + ) + .unwrap(); + + let plan = plan_from_batch(input_batch).await; + let projected = cast_to_table_schema(plan, &table_schema).unwrap(); + assert_eq!( + projected.schema().field_with_name("s").unwrap().data_type(), + &DataType::Struct(children) + ); + + let result = collect(projected).await; + assert_eq!(result.num_rows(), 2); + assert_eq!(result.column(0).null_count(), 2); + } } diff --git a/rust/lancedb/src/table/dataset.rs b/rust/lancedb/src/table/dataset.rs index 5e3733b85..af9fc2563 100644 --- a/rust/lancedb/src/table/dataset.rs +++ b/rust/lancedb/src/table/dataset.rs @@ -52,7 +52,7 @@ enum ConsistencyMode { /// refresh_window = min(3s, TTL/4) /// /// | t < TTL - refresh_window | t < TTL | t >= TTL | - /// | Return value | Background refresh & return value | syncronous refresh | + /// | Return value | Background refresh & return value | synchronous refresh | Eventual(BackgroundCache, Error>), } diff --git a/rust/lancedb/src/table/freshness.rs b/rust/lancedb/src/table/freshness.rs new file mode 100644 index 000000000..b2c933cd8 --- /dev/null +++ b/rust/lancedb/src/table/freshness.rs @@ -0,0 +1,1808 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Source-change detection for computed columns. +//! +//! A refresh fills nulls, so once a value is durable nothing recomputes it and +//! a later write to one of its inputs leaves it stale forever. Two stamps in +//! the column's field metadata close that: the definition it was computed +//! under, and a per-fragment signature of the input storage it was computed +//! from. A refresh compares both with the manifest and recomputes what +//! disagrees. Signatures are read from manifests, never from data. +//! +//! The map is not stored in the manifest: it is one entry per fragment per +//! column, which would dominate the manifest of a large table with many +//! computed columns. It lives in an immutable sidecar object under +//! `_computed/`, named by its content digest, and the field metadata holds +//! only the reference. Sidecars no retained version references are removed +//! by [`prune_sidecars`]. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::ops::Range; + +use arrow_array::{Array, UInt64Array}; +use futures::TryStreamExt; +use lance::Dataset; +use lance::dataset::refs::Ref; +use lance::dataset::transaction::Operation; +use lance_core::ROW_ADDR; +use lance_core::datatypes::{Field as LanceField, Schema as LanceSchema}; +use lance_io::object_store::{ObjectStore, uri_to_url}; +use lance_table::format::{DataFile, Fragment}; +use object_store::path::Path; +use roaring::RoaringBitmap; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::table::computed_columns::{ + DEFINITION_VERSION_META_KEY, RECORDED_AT_VERSION_META_KEY, SOURCE_SIGNATURE_META_KEY, +}; +use crate::{Error, Result}; + +/// A map recorded further back than this carries nothing through the +/// compactions since: the fragments they produced are recomputed instead. +const MAX_CARRY_FORWARD_VERSIONS: u64 = 1024; + +/// `{fragment id -> input signature}`. +pub type SignatureMap = BTreeMap; + +/// Every field an input covers, keyed by column path: the field's own id +/// first, then its ancestors', because a packed file records the physical +/// column under an ancestor's id. +pub type InputFields = BTreeMap, Vec>; + +fn invalid(message: String) -> Error { + Error::InvalidInput { message } +} + +/// FNV-1a over the text, as hex. Stable across processes and versions, which +/// a signature compared against a stored one has to be. +fn short_hash(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} + +/// Digest of the definition a column is computed under. +pub fn definition_version(definition: &str) -> String { + short_hash(definition) +} + +/// The fields the named input column paths cover, children included. Paths, +/// not ids, pair one schema with another: a rewrite renumbers fields. The key +/// is the path's components, so a field named `a.b` and a nested `a` -> `b` +/// are different columns. +pub fn fields_for_paths(schema: &LanceSchema, paths: &[String]) -> Result { + fn collect(field: &LanceField, path: Vec, ancestors: &[i32], out: &mut InputFields) { + let mut ids = vec![field.id]; + ids.extend_from_slice(ancestors); + for child in &field.children { + let mut child_path = path.clone(); + child_path.push(child.name.clone()); + collect(child, child_path, &ids, out); + } + out.insert(path, ids); + } + let mut out = InputFields::new(); + for field_path in paths { + let parts = lance_core::datatypes::parse_field_path(field_path)?; + let (root, rest) = parts + .split_first() + .ok_or_else(|| invalid("computed column input path is empty".to_string()))?; + let mut field = schema + .field(root) + .ok_or_else(|| invalid(format!("unknown computed column input '{field_path}'")))?; + let mut ancestors = Vec::new(); + for name in rest { + ancestors.insert(0, field.id); + field = field + .children + .iter() + .find(|child| child.name == *name) + .ok_or_else(|| invalid(format!("unknown computed column input '{field_path}'")))?; + } + collect(field, parts, &ancestors, &mut out); + } + Ok(out) +} + +/// Where one field's values come from in a fragment: the files and physical +/// columns storing it, and the overlays overriding cells of it, newest last +/// with the physical column and the cells each covers. A file stores the +/// field under its own id or, packed, under an ancestor's; `ids` is the +/// field's id followed by its ancestors'. Object identity is the resolved +/// location and path (see [`Bases`]); field ids are left out, since a +/// sibling column's rewrite re-labels them without touching a value. +#[derive(Debug, PartialEq, Eq)] +pub struct InputBasis { + files: Vec<(String, String, i32)>, + overlays: Vec<(String, String, i32, RoaringBitmap, u64)>, +} + +/// Where each storage base's data lives, as the store resolves it. A file +/// signs the same on the table that wrote it and on a branch or shallow +/// clone reading it through a base, while files under different bases +/// stay distinct. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bases { + own: String, + registered: HashMap, +} + +impl Bases { + pub fn of(dataset: &Dataset) -> Result { + let registered = dataset + .manifest() + .base_paths + .values() + .map(|base| Ok((base.id, location(&base.path, base.is_dataset_root)?))) + .collect::>()?; + Ok(Self { + own: location(dataset.uri(), true)?, + registered, + }) + } + + fn location(&self, base_id: Option) -> Result<&str> { + match base_id { + None => Ok(&self.own), + Some(id) => self + .registered + .get(&id) + .map(String::as_str) + .ok_or_else(|| invalid(format!("base path id {id} not found"))), + } + } +} + +/// `uri` as its store resolves it, with the data directory under a dataset +/// root. The query part only selects a commit handler, so it is left out. +fn location(uri: &str, is_dataset_root: bool) -> Result { + let url = uri_to_url(uri)?; + let mut location = url[..url::Position::BeforeQuery] + .trim_end_matches('/') + .to_string(); + if is_dataset_root { + location.push_str("/data"); + } + Ok(location) +} + +pub fn input_basis(bases: &Bases, metadata: &Fragment, ids: &[i32]) -> Result { + let column_of = |file: &DataFile| { + file.fields + .iter() + .position(|id| ids.contains(id)) + .map(|pos| (pos, file.column_indices.get(pos).copied().unwrap_or(-1))) + }; + let mut files = Vec::new(); + for file in &metadata.files { + if let Some((_, column)) = column_of(file) { + files.push(( + bases.location(file.base_id)?.to_string(), + file.path.clone(), + column, + )); + } + } + let mut overlays = Vec::new(); + for overlay in &metadata.overlays { + let Some((pos, column)) = column_of(&overlay.data_file) else { + continue; + }; + overlays.push(( + bases.location(overlay.data_file.base_id)?.to_string(), + overlay.data_file.path.clone(), + column, + overlay.coverage_for_field(pos)?.as_ref().clone(), + overlay.committed_version, + )); + } + Ok(InputBasis { files, overlays }) +} + +/// Identity of the input data a fragment currently holds: per input field, +/// its storage basis. Deletions are left out: a deleted row is never +/// computed, and the rows that stay keep their values. Physical identity, +/// not content, so a rewrite that preserves values still reads as a change; +/// compaction is followed separately. +pub fn fragment_input_signature( + bases: &Bases, + fragment: &Fragment, + inputs: &InputFields, +) -> Result { + let mut parts = Vec::new(); + for (path, ids) in inputs { + let basis = input_basis(bases, fragment, ids)?; + parts.push(format!("{}={basis:?}", path.join("."))); + } + Ok(short_hash(&parts.join("|"))) +} + +fn signature_of( + dataset: &Dataset, + fragment_id: u32, + inputs: &InputFields, +) -> Result> { + let bases = Bases::of(dataset)?; + dataset + .get_fragment(fragment_id as usize) + .map(|fragment| fragment_input_signature(&bases, fragment.metadata(), inputs)) + .transpose() +} + +/// Signatures for `fragment_ids` as `dataset` currently holds them. +pub fn signatures_for( + dataset: &Dataset, + fragment_ids: &[u32], + inputs: &InputFields, +) -> Result { + let wanted: HashSet = fragment_ids.iter().copied().collect(); + let bases = Bases::of(dataset)?; + dataset + .get_fragments() + .iter() + .filter(|fragment| wanted.contains(&(fragment.id() as u32))) + .map(|fragment| { + Ok(( + fragment.id() as u32, + fragment_input_signature(&bases, fragment.metadata(), inputs)?, + )) + }) + .collect() +} + +fn field_meta(dataset: &Dataset, column: &str, key: &str) -> Option { + dataset + .schema() + .field(column) + .and_then(|field| field.metadata.get(key)) + .cloned() +} + +/// What the column's stored map says. The three cases are distinct and the +/// callers act differently on each: see [`staleness_against`]. +#[derive(Debug)] +pub enum StoredSignatures { + /// No map has ever been written for this column. + Absent, + Present(SignatureMap), + /// A map exists but cannot be read. + Unreadable, +} + +/// Directory of signature sidecars, under the dataset root. +const SIDECAR_DIR: &str = "_computed"; +/// Metadata value prefix referencing a sidecar by its content digest. +const SIDECAR_REF: &str = "sidecar:"; +const SIDECAR_MAGIC: &[u8; 4] = b"CSIG"; +const SIDECAR_FORMAT: u8 = 1; +/// How long an unreferenced sidecar is presumed to be a stamp in flight: +/// lance's own threshold for unverified files, independent of how much +/// version history a cleanup keeps. +const SIDECAR_UNVERIFIED_THRESHOLD_DAYS: i64 = 7; + +/// The table's root: one `_computed/` per table, shared by main and its +/// branches, so a branch reads the stamps it was taken with. +fn dataset_root(dataset: &Dataset) -> Result { + Ok(dataset.branch_location().find_main()?.path) +} + +fn sidecar_path(dataset: &Dataset, digest: &str) -> Result { + Ok(dataset_root(dataset)? + .join(SIDECAR_DIR) + .join(format!("{digest}.sig"))) +} + +/// `CSIG`, format byte, entry count, then one fragment id and 8-byte +/// signature per entry, all little-endian; 12 bytes per fragment. +fn encode_sidecar(map: &SignatureMap) -> Result> { + let mut bytes = Vec::with_capacity(9 + map.len() * 12); + bytes.extend_from_slice(SIDECAR_MAGIC); + bytes.push(SIDECAR_FORMAT); + bytes.extend_from_slice( + &u32::try_from(map.len()) + .map_err(|_| invalid("too many fragments for a signature sidecar".to_string()))? + .to_le_bytes(), + ); + for (fragment_id, signature) in map { + let hash = u64::from_str_radix(signature, 16).map_err(|_| { + invalid(format!( + "signature '{signature}' is not a 64-bit hex digest" + )) + })?; + bytes.extend_from_slice(&fragment_id.to_le_bytes()); + bytes.extend_from_slice(&hash.to_le_bytes()); + } + Ok(bytes) +} + +fn decode_sidecar(bytes: &[u8]) -> Result { + let malformed = || invalid("signature sidecar is malformed".to_string()); + if bytes.len() < 9 || &bytes[..4] != SIDECAR_MAGIC || bytes[4] != SIDECAR_FORMAT { + return Err(malformed()); + } + let count = u32::from_le_bytes(bytes[5..9].try_into().map_err(|_| malformed())?) as usize; + let body = &bytes[9..]; + if body.len() != count * 12 { + return Err(malformed()); + } + Ok(body + .chunks_exact(12) + .map(|entry| { + let fragment_id = u32::from_le_bytes(entry[..4].try_into().unwrap()); + let hash = u64::from_le_bytes(entry[4..].try_into().unwrap()); + (fragment_id, format!("{hash:016x}")) + }) + .collect()) +} + +fn digest_of(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +async fn store(dataset: &Dataset) -> Result> { + Ok(dataset.object_store(None).await?) +} + +/// Write `map` as a sidecar and return the metadata value referencing it. +/// The object is named by its digest, so two columns with the same map +/// share one object and a rewrite is idempotent. +async fn write_sidecar(dataset: &Dataset, map: &SignatureMap) -> Result { + let bytes = encode_sidecar(map)?; + let digest = digest_of(&bytes); + store(dataset) + .await? + .put(&sidecar_path(dataset, &digest)?, &bytes) + .await?; + Ok(format!("{SIDECAR_REF}{digest}")) +} + +/// A map is read from the table's own directory, then through the bases a +/// shallow clone reads its data from, where the source stamped it. A copy +/// found through a base is put in the table's own directory so it outlives +/// the source's pruning; a failed copy only costs the next lookup. +async fn read_sidecar(dataset: &Dataset, digest: &str) -> Result { + let own = sidecar_path(dataset, digest)?; + let store = store(dataset).await?; + let bytes = match store.read_one_all(&own).await { + Ok(bytes) => bytes, + Err(error) if error.is_not_found() => { + let bytes = read_sidecar_through_bases(dataset, digest).await?; + if let Err(error) = store.put(&own, &bytes).await { + log::warn!( + "computed column signature sidecar {digest} could not be copied to {own}: {error}" + ); + } + bytes + } + Err(error) => return Err(error.into()), + }; + if digest_of(&bytes) != digest { + return Err(invalid(format!( + "signature sidecar {digest} does not match its digest" + ))); + } + decode_sidecar(&bytes) +} + +async fn read_sidecar_through_bases(dataset: &Dataset, digest: &str) -> Result { + let registry = dataset.session().store_registry(); + for base in dataset.manifest().base_paths.values() { + if !base.is_dataset_root { + continue; + } + let path = base.extract_path(registry.clone())?; + // A base cloned from a branch is named after it and sits at + // `/tree/`; the stamps are at that root. + let below_root = base + .name + .as_deref() + .map_or(0, |branch| 1 + branch.split('/').count()); + let count = path.parts().count(); + let root = Path::from_iter(path.parts().take(count.saturating_sub(below_root))); + let location = root.join(SIDECAR_DIR).join(format!("{digest}.sig")); + match dataset + .object_store(Some(base.id)) + .await? + .read_one_all(&location) + .await + { + Ok(bytes) => return Ok(bytes), + Err(error) if error.is_not_found() => continue, + Err(error) => return Err(error.into()), + } + } + Err(invalid(format!( + "signature sidecar {digest} is in neither the table's directory nor a base's" + ))) +} + +/// Remove signature sidecars that no version still present references, +/// the counterpart of lance's version cleanup for `_computed/`, with the +/// same protection for objects still being published: a sidecar is put +/// before the commit that references it, so one younger than +/// [`SIDECAR_UNVERIFIED_THRESHOLD_DAYS`] is left alone unless +/// `delete_unverified`. Returns how many were removed. +pub async fn prune_sidecars(dataset: &Dataset, delete_unverified: bool) -> Result { + // The directory serves main and every branch, so the references are + // collected across all of them whichever handle prunes. + let main; + let dataset = if dataset.manifest().branch.is_some() { + main = dataset.checkout_version(Ref::Version(None, None)).await?; + &main + } else { + dataset + }; + let store = store(dataset).await?; + let dir = dataset_root(dataset)?.join(SIDECAR_DIR); + let unmodified_since = (!delete_unverified) + .then(|| chrono::Utc::now() - chrono::Duration::days(SIDECAR_UNVERIFIED_THRESHOLD_DAYS)); + let present: Vec = match store + .read_dir_all(&dir, unmodified_since) + .try_collect::>() + .await + { + Ok(objects) => objects + .into_iter() + .filter_map(|object| { + object + .location + .filename() + .and_then(|name| name.strip_suffix(".sig")) + .map(str::to_string) + }) + .collect(), + Err(_) => return Ok(0), + }; + if present.is_empty() { + return Ok(0); + } + let mut referenced = HashSet::new(); + referenced_digests(dataset, &mut referenced).await?; + for branch in dataset.list_branches().await?.keys() { + referenced_digests(&dataset.checkout_branch(branch).await?, &mut referenced).await?; + } + let mut removed = 0; + for digest in present { + if !referenced.contains(&digest) { + store.delete(&sidecar_path(dataset, &digest)?).await?; + removed += 1; + } + } + Ok(removed) +} + +/// The sidecars every retained version of `dataset` references. +async fn referenced_digests(dataset: &Dataset, into: &mut HashSet) -> Result<()> { + for version in dataset.versions().await? { + let at = dataset.checkout_version(version.version).await?; + for field in at.schema().fields_pre_order() { + if let Some(digest) = field + .metadata + .get(SOURCE_SIGNATURE_META_KEY) + .and_then(|value| value.strip_prefix(SIDECAR_REF)) + { + into.insert(digest.to_string()); + } + } + } + Ok(()) +} + +/// Read the column's stored map. An unreadable map is a state, not an error: +/// failing here would make the column permanently unrefreshable, and the +/// unknown recomputes like every other unknown here. +pub async fn stored_signatures(dataset: &Dataset, column: &str) -> StoredSignatures { + let Some(encoded) = field_meta(dataset, column, SOURCE_SIGNATURE_META_KEY) else { + return StoredSignatures::Absent; + }; + // Inline JSON is the declaration's empty seed and the pre-sidecar form. + let read = match encoded.strip_prefix(SIDECAR_REF) { + Some(digest) => read_sidecar(dataset, digest).await, + None => serde_json::from_str(&encoded).map_err(|e| invalid(e.to_string())), + }; + match read { + Ok(map) => StoredSignatures::Present(map), + Err(error) => { + log::warn!( + "computed column '{column}' source signature map is unreadable ({error}); every fragment will be recomputed" + ); + StoredSignatures::Unreadable + } + } +} + +/// What a refresh must recompute beyond the null rows. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct StalenessPlan { + /// The definition changed, so every row is stale whatever its signature. + pub recompute_all: bool, + /// Fragments whose inputs moved since they were computed, or that were + /// never recorded. + pub dirty: HashSet, + /// Fragments a compaction produced from fresh ones, at their current + /// signature: not dirty, and for the seal to record. + pub inherited: SignatureMap, +} + +impl StalenessPlan { + pub fn is_dirty(&self, fragment_id: u32) -> bool { + self.recompute_all || self.dirty.contains(&fragment_id) + } +} + +/// A version of the log since the stamp that bears on carrying freshness +/// forward: an append, whose fragments hold no computed value yet, or a +/// compaction's rewrite groups, consumed and produced fragment ids. +enum Step { + Append, + Compaction(Vec<(Vec, Vec)>), +} + +/// The step committed at `version`. A compaction's transaction records a +/// new fragment before its id is assigned, so produced ids come from the +/// version's manifest, matched by data file. Every other operation is +/// skipped: a row-moving update rewrites the rows it moves, so it does not +/// carry their inputs unchanged. +async fn step_at(dataset: &Dataset, version: u64) -> Result> { + let transaction = match dataset.read_transaction_by_version(version).await { + Ok(Some(transaction)) => transaction, + // A branch or clone holds no manifest before the version it was + // taken from; what happened there is unknown, and unknown is stale. + Ok(None) | Err(lance_core::Error::DatasetNotFound { .. }) => return Ok(None), + Err(error) => return Err(error.into()), + }; + let groups = match &transaction.operation { + Operation::Append { .. } => return Ok(Some(Step::Append)), + Operation::Rewrite { groups, .. } => groups, + _ => return Ok(None), + }; + let at = dataset.checkout_version(version).await?; + let bases = Bases::of(&at)?; + let identity = |file: &DataFile| -> Result<(String, String)> { + Ok((bases.location(file.base_id)?.to_string(), file.path.clone())) + }; + let mut by_file = BTreeMap::new(); + for fragment in at.get_fragments() { + for file in &fragment.metadata().files { + by_file.insert(identity(file)?, fragment.id() as u32); + } + } + let compactions = groups + .iter() + .map(|group| { + let consumed = group.old_fragments.iter().map(|f| f.id as u32).collect(); + let produced = group + .new_fragments + .iter() + .map(|fragment| { + let mut found = None; + for file in &fragment.files { + if let Some(id) = by_file.get(&identity(file)?) { + found = Some(*id); + break; + } + } + found.ok_or_else(|| { + invalid(format!( + "a fragment added in version {version} is not in that version's manifest" + )) + }) + }) + .collect::>>()?; + Ok((consumed, produced)) + }) + .collect::>>()?; + Ok(Some(Step::Compaction(compactions))) +} + +/// Manifests since the stamp, each loaded once. +struct Manifests<'a> { + dataset: &'a Dataset, + loaded: BTreeMap, +} + +impl Manifests<'_> { + async fn at(&mut self, version: u64) -> Result<&Dataset> { + if !self.loaded.contains_key(&version) { + let manifest = self.dataset.checkout_version(version).await?; + self.loaded.insert(version, manifest); + } + Ok(&self.loaded[&version]) + } +} + +/// Whether a fragment consumed by a compaction was created by an append +/// since the stamp and its inputs never moved after: `current` is its +/// signature just before the compaction. LanceDB's writes leave a computed +/// column null (see `ensure_not_written`), but a raw append need not, so +/// the rows it contributed to the product are checked to hold no value +/// (`holds_values_in`) before the product inherits freshness. +async fn appended_untouched( + manifests: &mut Manifests<'_>, + appends: &[u64], + fragment_id: u32, + current: Option<&String>, + inputs: &InputFields, +) -> Result { + let Some(current) = current else { + return Ok(false); + }; + for &version in appends.iter().rev() { + let Some(born) = signature_of(manifests.at(version).await?, fragment_id, inputs)? else { + continue; + }; + if signature_of(manifests.at(version - 1).await?, fragment_id, inputs)?.is_some() { + // Live before this append: born earlier. + continue; + } + return Ok(&born == current); + } + Ok(false) +} + +/// Whether `column` holds a value in any of `ranges`, offsets within the +/// fragment. Compaction scans its sources in order, so the rows an appended +/// source contributed sit at known offsets of the product. +async fn holds_values_in( + dataset: &Dataset, + fragment_id: u32, + column: &str, + ranges: &[Range], +) -> Result { + let Some(fragment) = dataset.get_fragment(fragment_id as usize) else { + return Ok(false); + }; + let mut scanner = dataset.scan(); + scanner + .with_fragments(vec![fragment.metadata().clone()]) + .with_row_address() + .project(&[column])? + .filter(&format!( + "{} IS NOT NULL", + super::refresh::quote_identifier(column) + ))?; + let mut batches = scanner.try_into_stream().await?; + while let Some(batch) = batches.try_next().await? { + let addresses = batch + .column_by_name(ROW_ADDR) + .and_then(|column| column.as_any().downcast_ref::()) + .ok_or_else(|| invalid("row addresses missing from a freshness scan".to_string()))?; + if addresses + .iter() + .flatten() + .map(|address| address & 0xFFFF_FFFF) + .any(|offset| ranges.iter().any(|range| range.contains(&offset))) + { + return Ok(true); + } + } + Ok(false) +} + +/// Compaction copies inputs verbatim, so a fragment it produced from +/// recorded fragments whose inputs had not moved is as fresh as they were, +/// and a fragment an append created since the stamp, untouched after and +/// unfilled in the product, changes nothing (`appended_untouched`). +/// Followed from the version the map was recorded at through every +/// compaction since, so a chain of them carries too. Returns the produced +/// fragments' signatures at production; the caller compares each with the +/// current manifest, which catches anything written to them afterwards. +async fn carried_forward( + dataset: &Dataset, + column: &str, + stored: &SignatureMap, + inputs: &InputFields, +) -> Result { + let Some(recorded_at) = field_meta(dataset, column, RECORDED_AT_VERSION_META_KEY) + .and_then(|version| version.parse::().ok()) + else { + return Ok(SignatureMap::new()); + }; + let to = dataset.version().version; + if to <= recorded_at || to - recorded_at > MAX_CARRY_FORWARD_VERSIONS { + return Ok(SignatureMap::new()); + } + let mut fresh = stored.clone(); + let mut inherited = SignatureMap::new(); + let mut appends = Vec::new(); + let mut manifests = Manifests { + dataset, + loaded: BTreeMap::new(), + }; + for version in (recorded_at + 1)..=to { + let compactions = match step_at(dataset, version).await? { + None => continue, + Some(Step::Append) => { + appends.push(version); + continue; + } + Some(Step::Compaction(compactions)) => compactions, + }; + for (consumed, produced) in compactions { + // Each source's signature and live rows just before the + // compaction; live rows place its contribution in the product. + let mut sources = Vec::with_capacity(consumed.len()); + { + let before = manifests.at(version - 1).await?; + for id in &consumed { + let live = match before.get_fragment(*id as usize) { + Some(fragment) => fragment.count_rows(None).await? as u64, + None => 0, + }; + sources.push((*id, signature_of(before, *id, inputs)?, live)); + } + } + let mut all_fresh = !consumed.is_empty(); + let mut appended = Vec::new(); + let mut offset = 0u64; + for (id, current, live) in sources { + let recorded = matches!( + (fresh.get(&id), current.as_ref()), + (Some(recorded), Some(current)) if recorded == current + ); + if !recorded { + if appended_untouched(&mut manifests, &appends, id, current.as_ref(), inputs) + .await? + { + appended.push(offset..offset + live); + } else { + all_fresh = false; + break; + } + } + offset += live; + } + if !all_fresh { + continue; + } + let after = manifests.at(version).await?; + // The products in order, each with its share of the appended + // rows; a value there was supplied by the append, not computed. + let mut base = 0u64; + let mut unfilled = true; + for id in &produced { + let rows = match after.get_fragment(*id as usize) { + Some(fragment) => fragment.count_rows(None).await? as u64, + None => 0, + }; + let local: Vec> = appended + .iter() + .filter(|range| range.start < base + rows && range.end > base) + .map(|range| range.start.max(base) - base..range.end.min(base + rows) - base) + .collect(); + if !local.is_empty() && holds_values_in(after, *id, column, &local).await? { + unfilled = false; + break; + } + base += rows; + } + if !unfilled { + continue; + } + for id in &produced { + if let Some(signature) = signature_of(after, *id, inputs)? { + fresh.insert(*id, signature.clone()); + inherited.insert(*id, signature); + } + } + } + } + Ok(inherited) +} + +/// Decide what is stale, from the manifest alone. +/// +/// A column with no map at all was declared before signatures existed. It +/// keeps its null-fill behavior until its first stamp enrolls it (see +/// [`record_freshness`]). A map that omits a fragment is authoritative: the +/// fragment's input state is unknown, and unknown recomputes -- unless a +/// compaction of fresh fragments produced it, which is followed. Lineage is +/// read only when a live fragment has no entry, so a plan over a recorded +/// table costs no transaction reads. +pub async fn staleness_against( + dataset: &Dataset, + column: &str, + definition_version: &str, + inputs: &InputFields, +) -> Result { + let stored = match stored_signatures(dataset, column).await { + StoredSignatures::Absent => return Ok(StalenessPlan::default()), + StoredSignatures::Unreadable => { + return Ok(StalenessPlan { + recompute_all: true, + ..Default::default() + }); + } + StoredSignatures::Present(stored) => stored, + }; + let stored_version = field_meta(dataset, column, DEFINITION_VERSION_META_KEY); + if stored_version.is_some_and(|version| version != definition_version) { + return Ok(StalenessPlan { + recompute_all: true, + ..Default::default() + }); + } + let unrecorded = dataset + .get_fragments() + .iter() + .any(|fragment| !stored.contains_key(&(fragment.id() as u32))); + let mut inherited = if unrecorded { + carried_forward(dataset, column, &stored, inputs).await? + } else { + SignatureMap::new() + }; + let mut dirty = HashSet::new(); + let mut live = HashSet::new(); + let bases = Bases::of(dataset)?; + for fragment in dataset.get_fragments() { + let id = fragment.id() as u32; + live.insert(id); + let current = fragment_input_signature(&bases, fragment.metadata(), inputs)?; + if stored.get(&id).or_else(|| inherited.get(&id)) != Some(¤t) { + dirty.insert(id); + } + } + inherited.retain(|id, _| live.contains(id) && !dirty.contains(id)); + Ok(StalenessPlan { + recompute_all: false, + dirty, + inherited, + }) +} + +/// What [`record_freshness`] wrote: the table version the stamp landed at, +/// if it wrote one, and the entries recorded and dropped for having moved. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct FreshnessRecord { + pub version: Option, + pub recorded: usize, + pub moved: usize, +} + +/// Record, on `column`, the input state its fragments were computed from. +/// +/// `computed` is what this refresh computed in full, signed at the version +/// the values were read from. A column with no map yet was declared before +/// signatures existed; `pinned`, the version the refresh planned against, +/// is then the baseline: every fragment live there is trusted as it stood, +/// the null-fill contract its values were written under. Otherwise the +/// staleness decided on `pinned` supplies what compactions since the last +/// stamp carried forward. Either +/// way an entry is recorded only if `latest` still holds that input state -- +/// an input write can rebase under the output commit -- so a fragment whose +/// inputs moved stays unrecorded and is recomputed by the next refresh. +/// +/// Written once per refresh, after its data commit. +pub async fn record_freshness( + latest: &mut Dataset, + pinned: Option<(&Dataset, &StalenessPlan)>, + column: &str, + definition_version: &str, + inputs: &InputFields, + computed: SignatureMap, +) -> Result { + let absent = matches!( + stored_signatures(latest, column).await, + StoredSignatures::Absent + ); + let mut entries = SignatureMap::new(); + if let Some((pinned, staleness)) = pinned { + if absent { + let all: Vec = pinned + .get_fragments() + .iter() + .map(|fragment| fragment.id() as u32) + .collect(); + entries = signatures_for(pinned, &all, inputs)?; + } else { + entries = staleness.inherited.clone(); + } + } + entries.extend(computed); + if entries.is_empty() && !absent { + return Ok(FreshnessRecord::default()); + } + let fragments: Vec = entries.keys().copied().collect(); + let current = signatures_for(latest, &fragments, inputs)?; + let verified: SignatureMap = entries + .into_iter() + .filter(|(fragment_id, signature)| current.get(fragment_id) == Some(signature)) + .collect(); + let recorded = verified.len(); + let version = write_signatures(latest, column, definition_version, verified).await?; + Ok(FreshnessRecord { + version: Some(version), + recorded, + moved: fragments.len() - recorded, + }) +} + +/// Merge `entries` into the column's stored map and stamp the definition and +/// the version the map now describes. Merges rather than replaces: the +/// entries cover only the fragments this refresh wrote, and every fragment it +/// skipped keeps the entry an earlier one left. Entries for fragments no +/// longer in the manifest are dropped, so compaction cannot grow the map +/// without bound. Returns the version the stamp landed at. +pub async fn write_signatures( + dataset: &mut Dataset, + column: &str, + definition_version: &str, + entries: SignatureMap, +) -> Result { + let live: HashSet = dataset + .get_fragments() + .iter() + .map(|fragment| fragment.id() as u32) + .collect(); + // An unreadable map is discarded rather than merged: nothing in it can be + // trusted, and its fragments recompute until a later refresh records them. + let mut merged = match stored_signatures(dataset, column).await { + StoredSignatures::Present(stored) => stored, + StoredSignatures::Absent | StoredSignatures::Unreadable => SignatureMap::new(), + }; + merged.extend(entries); + merged.retain(|fragment_id, _| live.contains(fragment_id)); + // The sidecar is durable before the commit references it; a failure in + // between leaves an unreferenced object for `prune_sidecars`. + let encoded = if merged.is_empty() { + "{}".to_string() + } else { + write_sidecar(dataset, &merged).await? + }; + let describes = dataset.version().version; + dataset + .update_field_metadata() + .update( + column, + [ + (SOURCE_SIGNATURE_META_KEY.to_string(), encoded), + ( + DEFINITION_VERSION_META_KEY.to_string(), + definition_version.to_string(), + ), + ( + RECORDED_AT_VERSION_META_KEY.to_string(), + describes.to_string(), + ), + ], + )? + .await?; + Ok(dataset.version().version) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use arrow_array::RecordBatchIterator; + use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; + use lance::dataset::{ + MergeInsertBuilder, MergeInsertWriteMode, NewColumnTransform, WhenMatched, WhenNotMatched, + WriteMode, WriteParams, + }; + use lance_file::version::ConcreteFileVersion; + use lance_table::format::overlay::{DataOverlayFile, OverlayCoverage}; + + const COLUMN: &str = "doubled"; + const EXPRESSION: &str = "value * 2"; + + /// Two fragments of 50 rows, `id` and `value`, with `doubled` declared + /// all-null against `value`, tracked from birth. + async fn table(uri: &str) -> Dataset { + let batch = arrow_array::record_batch!( + ("id", Int32, (0..100).collect::>()), + ("value", Int32, (0..100).collect::>()) + ) + .unwrap(); + let schema = batch.schema(); + let mut dataset = Dataset::write( + RecordBatchIterator::new(vec![Ok(batch)], schema), + uri, + Some(WriteParams { + mode: WriteMode::Create, + max_rows_per_file: 50, + ..Default::default() + }), + ) + .await + .unwrap(); + let mut metadata = std::collections::HashMap::from([ + ( + crate::table::computed_columns::COMPUTED_COLUMN_META_KEY.to_string(), + "true".to_string(), + ), + ( + crate::table::computed_columns::EXPRESSION_META_KEY.to_string(), + EXPRESSION.to_string(), + ), + ]); + metadata.insert(SOURCE_SIGNATURE_META_KEY.to_string(), "{}".to_string()); + dataset + .add_columns( + NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new(vec![ + ArrowField::new(COLUMN, DataType::Int64, true).with_metadata(metadata), + ]))), + None, + None, + ) + .await + .unwrap(); + dataset + } + + fn inputs(dataset: &Dataset) -> InputFields { + fields_for_paths(dataset.schema(), &["value".to_string()]).unwrap() + } + + async fn plan(dataset: &Dataset) -> StalenessPlan { + staleness_against( + dataset, + COLUMN, + &definition_version(EXPRESSION), + &inputs(dataset), + ) + .await + .unwrap() + } + + async fn stamp_all(dataset: &mut Dataset) { + let ids = inputs(dataset); + let frags: Vec = dataset + .get_fragments() + .iter() + .map(|f| f.id() as u32) + .collect(); + let entries = signatures_for(dataset, &frags, &ids).unwrap(); + write_signatures(dataset, COLUMN, &definition_version(EXPRESSION), entries) + .await + .unwrap(); + } + + /// Strip the refresh's own keys: a column from before signatures existed. + async fn make_legacy(dataset: &mut Dataset) { + let declaration = dataset + .schema() + .field(COLUMN) + .unwrap() + .metadata + .iter() + .filter(|(key, _)| !key.starts_with("computed_refresh.")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + dataset + .update_field_metadata() + .replace(COLUMN, declaration) + .unwrap() + .await + .unwrap(); + assert!(matches!( + stored_signatures(dataset, COLUMN).await, + StoredSignatures::Absent + )); + } + + /// Rewrite `value` of the row with `id` in place: a partial merge-insert + /// attaches a new column file to the row's fragment, keeping its id. + async fn rewrite_value(dataset: &mut Dataset, id: i32) { + let batch = + arrow_array::record_batch!(("id", Int32, [id]), ("value", Int32, [1000])).unwrap(); + let schema = batch.schema(); + let mut builder = + MergeInsertBuilder::try_new(Arc::new(dataset.clone()), vec!["id".to_string()]).unwrap(); + builder + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::DoNothing) + .write_mode(MergeInsertWriteMode::RewriteColumns); + let (updated, _) = builder + .try_build() + .unwrap() + .execute_reader(RecordBatchIterator::new([Ok(batch)], schema)) + .await + .unwrap(); + *dataset = (*updated).clone(); + } + + async fn compact(dataset: &mut Dataset) -> u32 { + lance::dataset::optimize::compact_files( + dataset, + lance::dataset::optimize::CompactionOptions { + target_rows_per_fragment: 1000, + ..Default::default() + }, + None, + ) + .await + .unwrap(); + dataset.checkout_latest().await.unwrap(); + dataset.get_fragments()[0].id() as u32 + } + + /// `a.b` under `root` and a nested `a` -> `b` are different columns with + /// two bases; a dotted-string key would fold them into one. + #[test] + fn a_dotted_field_name_is_not_a_nested_path() { + let leaf = |name: &str| ArrowField::new(name, DataType::Int32, true); + let root = ArrowField::new( + "root", + DataType::Struct( + vec![ + ArrowField::new("a", DataType::Struct(vec![leaf("b")].into()), true), + leaf("a.b"), + ] + .into(), + ), + true, + ); + let schema = LanceSchema::try_from(&ArrowSchema::new(vec![root])).unwrap(); + let ids = fields_for_paths(&schema, &["root".to_string()]).unwrap(); + let path = |parts: &[&str]| parts.iter().map(|p| p.to_string()).collect::>(); + let nested = &ids[&path(&["root", "a", "b"])]; + let dotted = &ids[&path(&["root", "a.b"])]; + assert_ne!(nested[0], dotted[0], "{ids:?}"); + assert_eq!(ids.len(), 4, "{ids:?}"); + } + + /// A packed file records the physical column under an ancestor's id, so a + /// nested input's basis is found through its ancestors. + #[test] + fn a_packed_nested_input_has_a_file_basis() { + let word_count = ArrowField::new("word_count", DataType::Int32, true); + let metrics = ArrowField::new("metrics", DataType::Struct(vec![word_count].into()), true); + let analysis = ArrowField::new("analysis", DataType::Struct(vec![metrics].into()), true); + let schema = LanceSchema::try_from(&ArrowSchema::new(vec![analysis])).unwrap(); + let ids = fields_for_paths(&schema, &["analysis.metrics.word_count".to_string()]).unwrap(); + let word_count = &ids[&["analysis", "metrics", "word_count"] + .map(String::from) + .to_vec()]; + let analysis = schema.field("analysis").unwrap().id; + assert_eq!(word_count.last(), Some(&analysis), "{word_count:?}"); + let mut fragment = Fragment::new(0); + fragment.files.push(DataFile::new( + "packed.lance", + vec![analysis], + vec![3], + ConcreteFileVersion::V2_2, + None, + None, + )); + let basis = input_basis(&bases("memory://t"), &fragment, word_count).unwrap(); + assert_eq!( + basis.files, + vec![("memory://t/data".to_string(), "packed.lance".to_string(), 3)] + ); + } + + fn bases(root: &str) -> Bases { + Bases { + own: location(root, true).unwrap(), + registered: HashMap::new(), + } + } + + /// A file is identified by where its store resolves it: the table that + /// wrote it and a clone reading it through a base agree, however the + /// root was spelled, and a different root is a different file. + #[test] + fn a_file_signs_by_its_resolved_location() { + let file = |base_id| { + let mut fragment = Fragment::new(0); + fragment.files.push(DataFile::new( + "a.lance", + vec![7], + vec![0], + ConcreteFileVersion::V2_2, + None, + base_id, + )); + fragment + }; + let source = bases("/t/source/"); + let mut clone = bases("/t/clone"); + clone + .registered + .insert(1, location("/t/source", true).unwrap()); + let written = input_basis(&source, &file(None), &[7]).unwrap(); + assert_eq!(written, input_basis(&clone, &file(Some(1)), &[7]).unwrap()); + assert_ne!(written, input_basis(&clone, &file(None), &[7]).unwrap()); + assert_ne!( + written, + input_basis(&bases("/t/other"), &file(None), &[7]).unwrap() + ); + assert!(input_basis(&source, &file(Some(1)), &[7]).is_err()); + } + + /// An overlay that stores the input in another physical column of the + /// same object is a different basis. + #[test] + fn an_overlay_column_remap_changes_the_basis() { + let overlay = |column: i32| { + let mut fragment = Fragment::new(0); + fragment.overlays.push(DataOverlayFile { + data_file: DataFile::new( + "overlay.lance", + vec![7], + vec![column], + ConcreteFileVersion::V2_2, + None, + None, + ), + coverage: OverlayCoverage::dense(RoaringBitmap::from_iter([0u32])), + committed_version: 2, + }); + input_basis(&bases("memory://t"), &fragment, &[7]).unwrap() + }; + assert_ne!(overlay(0), overlay(1)); + assert_eq!(overlay(0), overlay(0)); + } + + async fn sidecar_names(dataset: &Dataset) -> Vec { + let mut names = store(dataset) + .await + .unwrap() + .read_dir(dataset_root(dataset).unwrap().join(SIDECAR_DIR)) + .await + .unwrap_or_default(); + names.sort(); + names + } + + fn signature_ref(dataset: &Dataset, column: &str) -> String { + field_meta(dataset, column, SOURCE_SIGNATURE_META_KEY).unwrap() + } + + /// The manifest carries only a digest; the map itself is a sidecar the + /// reader fetches and verifies. The declaration's empty seed stays inline. + #[tokio::test] + async fn a_stamp_is_a_sidecar_the_manifest_only_references() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + assert_eq!(signature_ref(&dataset, COLUMN), "{}"); + stamp_all(&mut dataset).await; + let reference = signature_ref(&dataset, COLUMN); + let digest = reference.strip_prefix(SIDECAR_REF).unwrap(); + assert_eq!(reference.len(), SIDECAR_REF.len() + 64, "{reference}"); + assert_eq!(sidecar_names(&dataset).await, vec![format!("{digest}.sig")]); + let StoredSignatures::Present(stored) = stored_signatures(&dataset, COLUMN).await else { + panic!("sidecar unreadable"); + }; + assert_eq!( + stored, + signatures_for(&dataset, &[0, 1], &inputs(&dataset)).unwrap() + ); + } + + /// Two columns with the same inputs share one sidecar, and a rewrite of + /// the same map is idempotent. + #[tokio::test] + async fn identical_maps_share_one_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + stamp_all(&mut dataset).await; + let ids = inputs(&dataset); + let entries = signatures_for(&dataset, &[0, 1], &ids).unwrap(); + write_signatures( + &mut dataset, + "value", + &definition_version(EXPRESSION), + entries, + ) + .await + .unwrap(); + assert_eq!(sidecar_names(&dataset).await.len(), 1); + assert_eq!( + signature_ref(&dataset, COLUMN), + signature_ref(&dataset, "value") + ); + } + + /// A sidecar that is missing or whose bytes do not match the digest is + /// unreadable: everything recomputes and the next stamp replaces it. + #[tokio::test] + async fn a_missing_or_corrupt_sidecar_recomputes_everything() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let digest = signature_ref(&dataset, COLUMN) + .strip_prefix(SIDECAR_REF) + .unwrap() + .to_string(); + let path = sidecar_path(&dataset, &digest).unwrap(); + let object_store = store(&dataset).await.unwrap(); + object_store.put(&path, b"CSIG garbage").await.unwrap(); + assert!(plan(&dataset).await.recompute_all); + object_store.delete(&path).await.unwrap(); + assert!(plan(&dataset).await.recompute_all); + stamp_all(&mut dataset).await; + assert_eq!(plan(&dataset).await, StalenessPlan::default()); + } + + /// A map written inline, the pre-sidecar form, is still read. + #[tokio::test] + async fn an_inline_map_is_still_read() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + let ids = inputs(&dataset); + let entries = signatures_for(&dataset, &[0, 1], &ids).unwrap(); + dataset + .update_field_metadata() + .update( + COLUMN, + [ + ( + SOURCE_SIGNATURE_META_KEY.to_string(), + serde_json::to_string(&entries).unwrap(), + ), + ( + DEFINITION_VERSION_META_KEY.to_string(), + definition_version(EXPRESSION), + ), + ], + ) + .unwrap() + .await + .unwrap(); + assert_eq!(plan(&dataset).await, StalenessPlan::default()); + } + + /// Pruning removes only sidecars no version still present references: + /// an older stamp survives while its version does, and goes with it. + #[tokio::test] + async fn pruning_follows_version_retention() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + let ids = inputs(&dataset); + let first = signatures_for(&dataset, &[0], &ids).unwrap(); + write_signatures(&mut dataset, COLUMN, &definition_version(EXPRESSION), first) + .await + .unwrap(); + stamp_all(&mut dataset).await; + assert_eq!(sidecar_names(&dataset).await.len(), 2); + // Orphan from a stamp that never committed. + store(&dataset) + .await + .unwrap() + .put(&sidecar_path(&dataset, "orphan").unwrap(), b"CSIG") + .await + .unwrap(); + assert_eq!(prune_sidecars(&dataset, true).await.unwrap(), 1); + assert_eq!(sidecar_names(&dataset).await.len(), 2); + + dataset + .cleanup_old_versions(chrono::Duration::zero(), Some(true), None) + .await + .unwrap(); + assert_eq!(prune_sidecars(&dataset, true).await.unwrap(), 1); + let remaining = sidecar_names(&dataset).await; + let current = signature_ref(&dataset, COLUMN); + assert_eq!( + remaining, + vec![format!( + "{}.sig", + current.strip_prefix(SIDECAR_REF).unwrap() + )] + ); + } + + /// The gate's reproducer: a sidecar is put before the commit that + /// references it, so a prune that interleaves must leave a recent, + /// as yet unreferenced object alone whatever version retention the + /// caller chose; only an unverified prune takes it. + #[tokio::test] + async fn pruning_does_not_collect_an_in_flight_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + let ids = inputs(&dataset); + let entries = signatures_for(&dataset, &[0, 1], &ids).unwrap(); + let reference = write_sidecar(&dataset, &entries).await.unwrap(); + + let removed = prune_sidecars(&dataset, false).await.unwrap(); + assert_eq!(removed, 0); + dataset + .update_field_metadata() + .update(COLUMN, [(SOURCE_SIGNATURE_META_KEY.to_string(), reference)]) + .unwrap() + .await + .unwrap(); + assert!(matches!( + stored_signatures(&dataset, COLUMN).await, + StoredSignatures::Present(_) + )); + + let orphan = write_sidecar(&dataset, &SignatureMap::from([(9, "0".repeat(16))])) + .await + .unwrap(); + assert_eq!( + prune_sidecars(&dataset, false).await.unwrap(), + 0, + "a recent orphan waits for its window" + ); + assert_eq!(prune_sidecars(&dataset, true).await.unwrap(), 1); + assert!(!sidecar_names(&dataset).await.contains(&format!( + "{}.sig", + orphan.strip_prefix(SIDECAR_REF).unwrap() + ))); + } + + /// A freshly declared column is tracked from birth: every fragment is + /// unrecorded, so every fragment is stale until a refresh records it. + #[tokio::test] + async fn a_declared_column_is_stale_until_recorded() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + assert_eq!(plan(&dataset).await.dirty, HashSet::from([0, 1])); + stamp_all(&mut dataset).await; + assert_eq!(plan(&dataset).await, StalenessPlan::default()); + } + + /// The signature answers "did my inputs move": a write to any other + /// column, the computed column included, leaves it alone. + #[tokio::test] + async fn an_unrelated_column_rewrite_leaves_the_signature_alone() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + let ids = inputs(&dataset); + let before = signatures_for(&dataset, &[0, 1], &ids).unwrap(); + dataset + .add_columns( + NewColumnTransform::SqlExpressions(vec![("extra".into(), "id * 3".into())]), + None, + None, + ) + .await + .unwrap(); + assert_eq!(before, signatures_for(&dataset, &[0, 1], &ids).unwrap()); + } + + /// An in-place write to one fragment's input keeps every fragment id, so + /// only the signature can notice -- and on that fragment alone. + #[tokio::test] + async fn an_in_place_input_change_dirties_only_its_own_fragment() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + rewrite_value(&mut dataset, 60).await; + let stale = plan(&dataset).await; + assert_eq!(stale.dirty, HashSet::from([1]), "{stale:?}"); + } + + /// A deleted row is never computed and the rows that stay keep their + /// values, so a delete dirties nothing. + #[tokio::test] + async fn a_delete_dirties_nothing() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + dataset.delete("id >= 60 AND id < 70").await.unwrap(); + assert_eq!(plan(&dataset).await, StalenessPlan::default()); + } + + /// A definition change makes every row stale whatever the signatures say. + #[tokio::test] + async fn a_definition_change_recomputes_every_row() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let rebound = staleness_against(&dataset, COLUMN, "other", &inputs(&dataset)) + .await + .unwrap(); + assert!(rebound.recompute_all); + } + + /// A column declared before signatures existed carries no map, and keeps + /// null-fill behavior until its first stamp enrolls it -- at the pinned + /// state, not the latest: an input that moved in between is left out. + #[tokio::test] + async fn a_first_stamp_enrolls_an_older_column_as_it_stood() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + make_legacy(&mut dataset).await; + assert_eq!(plan(&dataset).await, StalenessPlan::default()); + let ids = inputs(&dataset); + let pinned = dataset.clone(); + let staleness = plan(&pinned).await; + rewrite_value(&mut dataset, 60).await; + let record = record_freshness( + &mut dataset, + Some((&pinned, &staleness)), + COLUMN, + &definition_version(EXPRESSION), + &ids, + SignatureMap::new(), + ) + .await + .unwrap(); + assert_eq!((record.recorded, record.moved), (1, 1)); + assert_eq!(record.version, Some(dataset.version().version)); + assert_eq!(plan(&dataset).await.dirty, HashSet::from([1])); + } + + /// Append `count` rows after `first_id`, the computed column null as a + /// write must leave it; returns the new fragment's id. + async fn append_rows(dataset: &mut Dataset, first_id: i32, count: i32) -> u32 { + let batch = arrow_array::record_batch!( + ( + "id", + Int32, + (first_id..first_id + count).collect::>() + ), + ( + "value", + Int32, + (first_id..first_id + count).collect::>() + ), + ("doubled", Int64, vec![None::; count as usize]) + ) + .unwrap(); + let schema = batch.schema(); + *dataset = Dataset::write( + RecordBatchIterator::new(vec![Ok(batch)], schema), + dataset.uri(), + Some(WriteParams { + mode: WriteMode::Append, + ..Default::default() + }), + ) + .await + .unwrap(); + dataset.get_fragments().last().unwrap().id() as u32 + } + + /// A fragment an append created since the stamp holds no computed value, + /// so a compaction folding it into recorded fragments produces a fresh + /// fragment: nothing recomputes, and the null fill covers the new rows. + #[tokio::test] + async fn a_compaction_folding_an_untouched_appended_fragment_carries_freshness_forward() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let appended = append_rows(&mut dataset, 100, 10).await; + assert_eq!(plan(&dataset).await.dirty, HashSet::from([appended])); + let compacted = compact(&mut dataset).await; + let stale = plan(&dataset).await; + assert!(stale.dirty.is_empty(), "{stale:?}"); + assert_eq!( + stale.inherited.keys().copied().collect::>(), + vec![compacted] + ); + } + + /// The same fragment with an input rewritten after the append is not + /// neutral: what it holds may have been computed from the older input. + #[tokio::test] + async fn a_compaction_folding_an_appended_fragment_whose_input_moved_recomputes() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + append_rows(&mut dataset, 100, 10).await; + rewrite_value(&mut dataset, 105).await; + let compacted = compact(&mut dataset).await; + let stale = plan(&dataset).await; + assert_eq!(stale.dirty, HashSet::from([compacted]), "{stale:?}"); + assert!(stale.inherited.is_empty()); + } + + /// A raw append can supply a computed value LanceDB's own writes never + /// do. The product holds it where the appended rows landed, so the + /// compaction is not carried: the value is recomputed, not certified. + #[tokio::test] + async fn a_compaction_folding_an_appended_fragment_with_values_recomputes() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let batch = arrow_array::record_batch!( + ("id", Int32, [100, 101]), + ("value", Int32, [100, 101]), + ("doubled", Int64, [None, Some(999_i64)]) + ) + .unwrap(); + let schema = batch.schema(); + dataset = Dataset::write( + RecordBatchIterator::new(vec![Ok(batch)], schema), + dataset.uri(), + Some(WriteParams { + mode: WriteMode::Append, + ..Default::default() + }), + ) + .await + .unwrap(); + let compacted = compact(&mut dataset).await; + let stale = plan(&dataset).await; + assert_eq!(stale.dirty, HashSet::from([compacted]), "{stale:?}"); + assert!(stale.inherited.is_empty()); + } + + /// Compaction copies inputs unchanged: a fragment it produced from + /// recorded, unmoved fragments is fresh, and the seal records it. One + /// produced from a fragment whose inputs had moved is not. + #[tokio::test] + async fn a_compaction_of_fresh_fragments_carries_freshness_forward() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let compacted = compact(&mut dataset).await; + let stale = plan(&dataset).await; + assert!(stale.dirty.is_empty(), "{stale:?}"); + assert_eq!( + stale.inherited.keys().copied().collect::>(), + vec![compacted] + ); + let ids = inputs(&dataset); + let pinned = dataset.clone(); + let record = record_freshness( + &mut dataset, + Some((&pinned, &stale)), + COLUMN, + &definition_version(EXPRESSION), + &ids, + SignatureMap::new(), + ) + .await + .unwrap(); + assert_eq!(record.recorded, 1); + let StoredSignatures::Present(stored) = stored_signatures(&dataset, COLUMN).await else { + panic!("stamped"); + }; + assert_eq!( + stored.keys().copied().collect::>(), + vec![compacted] + ); + + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + rewrite_value(&mut dataset, 60).await; + let compacted = compact(&mut dataset).await; + let stale = plan(&dataset).await; + assert_eq!(stale.dirty, HashSet::from([compacted]), "{stale:?}"); + assert!(stale.inherited.is_empty()); + } + + /// A recorded fragment whose signature no longer matches, or a fragment + /// the map omits without a compaction to explain it, is stale. + #[tokio::test] + async fn a_fragment_missing_from_the_stored_map_is_stale() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + let ids = inputs(&dataset); + let partial = signatures_for(&dataset, &[0], &ids).unwrap(); + write_signatures( + &mut dataset, + COLUMN, + &definition_version(EXPRESSION), + partial, + ) + .await + .unwrap(); + assert_eq!(plan(&dataset).await.dirty, HashSet::from([1])); + } + + /// An unreadable map recomputes everything rather than failing the + /// refresh, and the next stamp replaces it. + #[tokio::test] + async fn an_unreadable_stored_map_recomputes_rather_than_failing() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + dataset + .update_field_metadata() + .update( + COLUMN, + [(SOURCE_SIGNATURE_META_KEY.to_string(), "{".to_string())], + ) + .unwrap() + .await + .unwrap(); + assert!(plan(&dataset).await.recompute_all); + stamp_all(&mut dataset).await; + assert_eq!(plan(&dataset).await, StalenessPlan::default()); + } + async fn names_under(dataset: &Dataset, dir: Path) -> Vec { + let mut names = store(dataset) + .await + .unwrap() + .read_dir(dir.join(SIDECAR_DIR)) + .await + .unwrap_or_default(); + names.sort(); + names + } + + /// A branch starts with main's freshness, and its stamps join the + /// table's one sidecar directory rather than the branch's tree. + #[tokio::test] + async fn a_branch_starts_with_mains_freshness() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let mut branch = dataset + .create_branch("exp", dataset.version().version, None) + .await + .unwrap(); + assert_eq!(plan(&branch).await, StalenessPlan::default()); + rewrite_value(&mut branch, 3).await; + assert_eq!(plan(&branch).await.dirty, HashSet::from([0])); + stamp_all(&mut branch).await; + assert_eq!(plan(&branch).await, StalenessPlan::default()); + assert_eq!(sidecar_names(&dataset).await.len(), 2); + assert!( + names_under(&branch, branch.branch_location().path) + .await + .is_empty() + ); + assert_eq!(plan(&dataset).await, StalenessPlan::default()); + } + + /// A branch taken after an append main never stamped: the versions + /// before the branch are not in its tree, so the walk skips them and + /// the appended fragment is simply stale. + #[tokio::test] + async fn a_branch_walks_only_the_history_it_holds() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let appended = append_rows(&mut dataset, 100, 10).await; + let mut branch = dataset + .create_branch("exp", dataset.version().version, None) + .await + .unwrap(); + let staleness = plan(&branch).await; + assert_eq!(staleness.dirty, HashSet::from([appended])); + assert!(staleness.inherited.is_empty()); + let product = compact(&mut branch).await; + assert_eq!(plan(&branch).await.dirty, HashSet::from([product])); + } + + /// A shallow clone reads the map through the base it was cloned from + /// and keeps its own copy from then on. + #[tokio::test] + async fn a_shallow_clone_starts_with_its_sources_freshness() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().join("source").to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let target = dir.path().join("clone"); + let mut clone = dataset + .shallow_clone(target.to_str().unwrap(), dataset.version().version, None) + .await + .unwrap(); + assert_eq!(plan(&clone).await, StalenessPlan::default()); + assert_eq!(sidecar_names(&clone).await, sidecar_names(&dataset).await); + rewrite_value(&mut clone, 3).await; + assert_eq!(plan(&clone).await.dirty, HashSet::from([0])); + stamp_all(&mut clone).await; + assert_eq!(plan(&clone).await, StalenessPlan::default()); + assert_eq!(plan(&dataset).await, StalenessPlan::default()); + } + + /// A sidecar only a branch references survives a prune from either + /// handle until the branch is gone. + #[tokio::test] + async fn pruning_keeps_what_a_branch_references() { + let dir = tempfile::tempdir().unwrap(); + let mut dataset = table(dir.path().to_str().unwrap()).await; + stamp_all(&mut dataset).await; + let mains = signature_ref(&dataset, COLUMN); + let mut branch = dataset + .create_branch("exp", dataset.version().version, None) + .await + .unwrap(); + rewrite_value(&mut branch, 3).await; + stamp_all(&mut branch).await; + assert_ne!(signature_ref(&branch, COLUMN), mains); + assert_eq!(sidecar_names(&dataset).await.len(), 2); + assert_eq!(prune_sidecars(&dataset, true).await.unwrap(), 0); + assert_eq!(prune_sidecars(&branch, true).await.unwrap(), 0); + assert_eq!(plan(&branch).await, StalenessPlan::default()); + dataset.delete_branch("exp").await.unwrap(); + assert_eq!(prune_sidecars(&dataset, true).await.unwrap(), 1); + assert_eq!( + sidecar_names(&dataset).await, + vec![format!("{}.sig", mains.strip_prefix(SIDECAR_REF).unwrap())] + ); + } +} diff --git a/rust/lancedb/src/table/merge.rs b/rust/lancedb/src/table/merge.rs index ef2af8fe0..8f5585829 100644 --- a/rust/lancedb/src/table/merge.rs +++ b/rust/lancedb/src/table/merge.rs @@ -103,7 +103,7 @@ impl MergeInsertBuilder { /// but that behavior is subject to change. /// /// An optional condition may be specified. If it is, then only - /// matched rows that satisfy the condtion will be updated. Any + /// matched rows that satisfy the condition will be updated. Any /// rows that do not satisfy the condition will be left as they /// are. Failing to satisfy the condition does not cause a /// "matched row" to become a "not matched" row. diff --git a/rust/lancedb/src/table/merge/lsm.rs b/rust/lancedb/src/table/merge/lsm.rs index a06507ba0..1a1f0b28c 100644 --- a/rust/lancedb/src/table/merge/lsm.rs +++ b/rust/lancedb/src/table/merge/lsm.rs @@ -904,7 +904,7 @@ fn unsharded_shard_id() -> Uuid { /// Build a [`ShardWriterConfig`] from the persisted `writer_config_defaults`. /// -/// Unknown or unparseable keys are ignored; absent keys keep the +/// Unknown or unparsable keys are ignored; absent keys keep the /// [`ShardWriterConfig`] default. The shard id is set by `mem_wal_writer`. fn shard_writer_config_from_defaults(defaults: &HashMap) -> ShardWriterConfig { let mut config = ShardWriterConfig::default().with_shard_spec_id(SHARDING_SPEC_ID); diff --git a/rust/lancedb/src/table/optimize.rs b/rust/lancedb/src/table/optimize.rs index a42af7bce..c1b4490a3 100644 --- a/rust/lancedb/src/table/optimize.rs +++ b/rust/lancedb/src/table/optimize.rs @@ -8,7 +8,8 @@ use std::sync::Arc; -use lance::dataset::cleanup::RemovalStats; +use chrono::{DateTime, Utc}; +use lance::dataset::cleanup::{CleanupPolicyBuilder, RemovalStats}; use lance::dataset::optimize::{CompactionMetrics, IndexRemapperOptions, compact_files}; use lance::index::DatasetIndexExt; use lance_index::optimize::OptimizeOptions; @@ -134,9 +135,44 @@ pub(crate) async fn cleanup_old_versions( ) -> Result { table.dataset.ensure_mutable()?; let dataset = table.dataset.get().await?; - Ok(dataset + let stats = dataset .cleanup_old_versions(older_than, delete_unverified, error_if_tagged_old_versions) - .await?) + .await?; + // Computed-column signature sidecars live outside lance's directories; + // drop the ones the surviving versions no longer reference. + let removed = + super::freshness::prune_sidecars(&dataset, delete_unverified.unwrap_or(false)).await?; + if removed > 0 { + log::debug!("removed {removed} unreferenced computed-column signature sidecars"); + } + Ok(stats) +} + +/// Remove dataset versions committed before an absolute timestamp. +pub(crate) async fn cleanup_old_versions_before( + table: &NativeTable, + before_timestamp: DateTime, + delete_unverified: Option, + error_if_tagged_old_versions: Option, +) -> Result { + table.dataset.ensure_mutable()?; + let dataset = table.dataset.get().await?; + let mut policy = CleanupPolicyBuilder::default().before_timestamp(before_timestamp); + if let Some(delete_unverified) = delete_unverified { + policy = policy.delete_unverified(delete_unverified); + } + if let Some(error_if_tagged_old_versions) = error_if_tagged_old_versions { + policy = policy.error_if_tagged_old_versions(error_if_tagged_old_versions); + } + let stats = dataset.cleanup_with_policy(policy.build()).await?; + // Computed-column signature sidecars live outside lance's directories; + // drop the ones the surviving versions no longer reference. + let removed = + super::freshness::prune_sidecars(&dataset, delete_unverified.unwrap_or(false)).await?; + if removed > 0 { + log::debug!("removed {removed} unreferenced computed-column signature sidecars"); + } + Ok(stats) } /// Compact files in the dataset. diff --git a/rust/lancedb/src/table/query.rs b/rust/lancedb/src/table/query.rs index 6f6bbf372..d611fe8a5 100644 --- a/rust/lancedb/src/table/query.rs +++ b/rust/lancedb/src/table/query.rs @@ -110,7 +110,7 @@ fn requires_local_namespace_execution(query: &AnyQuery) -> bool { // pushing these down would silently ignore the user's setting. For use_lsm that // is worse than a tuning miss: MemWAL read routing lives only in `create_plan`, // so a pushed-down query would return stale base-only data with no error. - if query.base().use_lsm.is_some() { + if query.base().use_lsm.is_some() || query.base().take_offsets.is_some() { return true; } matches!( @@ -154,6 +154,13 @@ pub async fn create_plan( options: QueryExecutionOptions, ) -> Result> { let query = query.canonicalized()?; + if let AnyQuery::Query(request) = &query + && let Some(offsets) = &request.take_offsets + { + return crate::query::create_take_offsets_plan(table, request, offsets, options, false) + .await; + } + let query = match query { AnyQuery::VectorQuery(query) => query, AnyQuery::Query(query) => VectorQueryRequest::from_plain_query(query), diff --git a/rust/lancedb/src/table/refresh.rs b/rust/lancedb/src/table/refresh.rs index 511fce8ff..0119ba36d 100644 --- a/rust/lancedb/src/table/refresh.rs +++ b/rust/lancedb/src/table/refresh.rs @@ -3,9 +3,9 @@ //! Filling computed columns. //! -//! A row without a value gets one; a row that has one keeps it. Refresh is -//! therefore idempotent and does not observe input mutation -- once a row is -//! filled, changing what the expression reads leaves the stored result alone. +//! A row without a value gets one; a row that has one keeps it unless its +//! fragment's inputs moved since it was computed, which `freshness` decides +//! from the manifest and stamps after every fill. //! //! A column's computed inputs are filled first -- the dependency graph is //! walked once, each reachable column filled once in dependency order, each @@ -31,6 +31,7 @@ use std::collections::HashSet; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use arrow_array::{ Array, ArrayRef, BooleanArray, LargeBinaryArray, RecordBatch, RecordBatchOptions, StructArray, @@ -48,6 +49,7 @@ use lance_core::datatypes::{BlobHandling, Schema as LanceSchema}; use serde::{Deserialize, Serialize}; use super::computed_columns::{BoundExpression, ComputedColumnKind, computed_column_from_field}; +use super::freshness::{self, SignatureMap, StalenessPlan}; use super::{BaseTable, NativeTable}; use crate::job::Job; use crate::{Error, Result}; @@ -110,28 +112,82 @@ async fn execute_refresh_column_with_source( }; let output_is_blob = field.is_blob_v2(); + // Which fragments the null filter cannot speak for: their inputs moved + // since they were computed, or the definition did. Decided once, from + // the manifest the values are read from. + let inputs = freshness::fields_for_paths(dataset.schema(), &bound.inputs)?; + let bases = freshness::Bases::of(&dataset)?; + let definition = freshness::definition_version(&expression); + let staleness = freshness::staleness_against(&dataset, column, &definition, &inputs).await?; + let mut rows_filled = 0u64; let mut replacements = Vec::new(); + // Fragments this refresh computed in full, signed at the version read. + let mut computed = SignatureMap::new(); for fragment in dataset.get_fragments() { - let gained = count_fragment_gains(&dataset, &fragment, &bound, column).await?; - if gained == 0 { - continue; + let fragment_id = u32::try_from(fragment.id()).map_err(|_| Error::Runtime { + message: format!("fragment id {} does not fit a signature map", fragment.id()), + })?; + // A recompute rewrites every live row, so it is staged without the + // probe and counted as it fills; a null fill probes first, since a + // fragment with nothing to gain is not worth a write. + let recompute = staleness.is_dirty(fragment_id); + let whole = recompute || { + let (gained, unfilled) = + count_fragment_gains(&dataset, &fragment, &bound, column).await?; + if gained == 0 { + continue; + } + rows_filled += gained; + unfilled == u64::try_from(fragment.count_rows(None).await?).unwrap_or(u64::MAX) + }; + if whole { + computed.insert( + fragment_id, + freshness::fragment_input_signature(&bases, fragment.metadata(), &inputs)?, + ); } - rows_filled += gained; - let values = - fill_stream(&dataset, &fragment, bound.clone(), column, output_is_blob).await?; + let gained = Arc::new(AtomicU64::new(0)); + let values = fill_stream( + &dataset, + &fragment, + bound.clone(), + column, + output_is_blob, + recompute, + gained.clone(), + ) + .await?; replacements.push(fragment.write_columns(values, &column_schema).await?); + if recompute { + rows_filled += gained.load(Ordering::Relaxed); + } } let source_version = dataset.version().version; if replacements.is_empty() { + // Nothing to fill; the stamp may still have something to record -- a + // column not yet enrolled, or fragments a compaction carried. + let mut latest = (*dataset).clone(); + let stamped = record( + &mut latest, + (&dataset, &staleness), + column, + &definition, + &inputs, + computed, + ) + .await; + if stamped.is_some() { + table.dataset.update(latest); + } return Ok(RefreshExecution { result: RefreshColumnResult { rows_filled: 0, - version: source_version, + version: stamped.unwrap_or(source_version), }, source_version, - published_version: None, + published_version: stamped, }); } @@ -149,7 +205,17 @@ async fn execute_refresh_column_with_source( ) .await?; - let version = new_dataset.version().version; + let mut new_dataset = new_dataset; + let version = record( + &mut new_dataset, + (&dataset, &staleness), + column, + &definition, + &inputs, + computed, + ) + .await + .unwrap_or(new_dataset.version().version); table.dataset.update(new_dataset); Ok(RefreshExecution { result: RefreshColumnResult { @@ -161,6 +227,31 @@ async fn execute_refresh_column_with_source( }) } +/// Stamp the input state the refresh computed from (see +/// [`freshness::record_freshness`]); the version the stamp landed at, which +/// is the last one the refresh wrote. Never fails the refresh: the values +/// are committed, and a missing stamp only costs a recompute next time. +async fn record( + latest: &mut Dataset, + pinned: (&Dataset, &StalenessPlan), + column: &str, + definition: &str, + inputs: &freshness::InputFields, + computed: SignatureMap, +) -> Option { + match freshness::record_freshness(latest, Some(pinned), column, definition, inputs, computed) + .await + { + Ok(record) => record.version, + Err(error) => { + log::warn!( + "could not record the input state computed column '{column}' was refreshed from ({error}); its fragments will recompute on the next refresh" + ); + None + } + } +} + /// Refuse while a computed input still has rows a refresh of it would fill: /// read now, its placeholder null would be evaluated as a value and kept. async fn ensure_inputs_filled( @@ -188,7 +279,9 @@ async fn ensure_inputs_filled( let input_bound = super::computed_columns::bind(schema.clone(), input, expression)?; let mut unfilled = 0u64; for fragment in dataset.get_fragments() { - unfilled += count_fragment_gains(dataset, &fragment, &input_bound, input).await?; + unfilled += count_fragment_gains(dataset, &fragment, &input_bound, input) + .await? + .0; } if unfilled > 0 { return Err(Error::InvalidInput { @@ -436,32 +529,35 @@ fn blob_array_from_binary( /// Scans only the unfilled live rows -- deleted rows never reach the /// expression here, the filter having already excluded them -- and counts the /// non-null results. Exact, so it is both the staging decision and the -/// fragment's contribution to `rows_filled`. +/// fragment's contribution to `rows_filled`. Returns the gains and the rows +/// scanned. async fn count_fragment_gains( dataset: &Dataset, fragment: &FileFragment, bound: &BoundExpression, column: &str, -) -> Result { +) -> Result<(u64, u64)> { let mut scanner = dataset.scan(); scanner .with_fragments(vec![fragment.metadata().clone()]) .with_row_id() - .filter(&format!("{} IS NULL", quote_identifier(column)))? - .project(&bound.roots)?; + .project(&bound.roots)? + .filter(&format!("{} IS NULL", quote_identifier(column)))?; configure_blob_inputs(&mut scanner, dataset.schema(), bound, None)?; let mut gained = 0u64; + let mut considered = 0u64; let mut batches = scanner.try_into_stream().await?; while let Some(batch) = batches.try_next().await? { let evaluated = evaluate(bound, &evaluation_batch(&batch, bound, None)?)?; gained += (batch.num_rows() - evaluated.null_count()) as u64; + considered += batch.num_rows() as u64; } - Ok(gained) + Ok((gained, considered)) } /// Stream one fragment's column in physical order, filling the unfilled live -/// rows and keeping every other value. +/// rows -- every live row, for a recompute -- and keeping every other value. /// /// Deleted rows are carried through so the values line up positionally with /// the fragment's data files; they are never read back, but the column file @@ -472,6 +568,8 @@ async fn fill_stream( bound: Arc, column: &str, output_is_blob: bool, + recompute: bool, + gained: Arc, ) -> Result> + Send + use<>> { let mut projection: Vec = bound.roots.clone(); projection.push(column.to_string()); @@ -521,14 +619,20 @@ async fn fill_stream( .column_by_name(ROW_ID) .ok_or_else(|| missing(ROW_ID))?; - // Only an unfilled live row gains a value; a deleted row has a null - // row id and keeps its (null) slot. - let unfilled = arrow::compute::is_null(existing.as_ref())?; + // Only an unfilled live row gains a value, or every live row under a + // recompute; a deleted row has a null row id and keeps its (null) slot. let live = arrow::compute::is_not_null(row_ids.as_ref())?; - let fill = arrow::compute::and(&unfilled, &live)?; + let fill = if recompute { + live + } else { + let unfilled = arrow::compute::is_null(existing.as_ref())?; + arrow::compute::and(&unfilled, &live)? + }; let keep = arrow::compute::not(&fill)?; let computed = evaluate(&bound, &evaluation_batch(&batch, &bound, Some(&keep))?)?; + let values = arrow::compute::and(&fill, &arrow::compute::is_not_null(&computed)?)?; + gained.fetch_add(values.true_count() as u64, Ordering::Relaxed); let merged = arrow_select::zip::zip(&fill, &computed, existing)?; let merged = if output_is_blob { blob_array_from_binary(&merged, projected.field(0))? @@ -737,7 +841,8 @@ mod tests { .await .unwrap(); assert_eq!(no_op.rows_assigned, 0); - assert_eq!(no_op.source_version, 3); + // The fill, then the stamp recording what it computed from. + assert_eq!(no_op.source_version, 4); assert_eq!(no_op.published_version, None); } @@ -776,9 +881,77 @@ mod tests { ); } + /// A branch inherits the freshness main recorded: its first refresh + /// fills nothing, and only its own appends after that. + #[tokio::test] + async fn test_a_branch_inherits_freshness() { + let dir = tempfile::tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let batch = record_batch!(("x", Int32, vec![1, 2, 3])).unwrap(); + let table = conn.create_table("t", batch).execute().await.unwrap(); + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + let branch = table + .create_branch("exp", table.version().await.unwrap()) + .await + .unwrap(); + assert_eq!( + branch.refresh_column("doubled").await.unwrap().rows_filled, + 0 + ); + append(&branch, vec![4]).await; + assert_eq!( + branch.refresh_column("doubled").await.unwrap().rows_filled, + 1 + ); + assert_eq!( + table.refresh_column("doubled").await.unwrap().rows_filled, + 0 + ); + assert_eq!( + read(&branch, "doubled").await, + vec![Some(2), Some(4), Some(6), Some(8)] + ); + } + + /// A shallow clone inherits the freshness its source recorded. + #[tokio::test] + async fn test_a_shallow_clone_inherits_freshness() { + let dir = tempfile::tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let batch = record_batch!(("x", Int32, vec![1, 2, 3])).unwrap(); + let table = conn.create_table("t", batch).execute().await.unwrap(); + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + let clone = conn + .clone_table("copy", dir.path().join("t.lance").to_str().unwrap()) + .execute() + .await + .unwrap(); + assert_eq!( + clone.refresh_column("doubled").await.unwrap().rows_filled, + 0 + ); + append(&clone, vec![4]).await; + assert_eq!( + clone.refresh_column("doubled").await.unwrap().rows_filled, + 1 + ); + assert_eq!( + table.refresh_column("doubled").await.unwrap().rows_filled, + 0 + ); + } + /// A row is filled only by gaining a value, so an expression yielding null - /// settles at once instead of re-selecting the same rows forever. Nothing - /// is staged, so the version does not move either. + /// settles at once instead of re-selecting the same rows forever: the + /// second refresh finds the fragment signed and moves nothing. #[tokio::test] async fn test_refresh_converges_on_a_null_result() { let table = table_with("refresh_null_result", vec![1, 2, 3]).await; @@ -792,28 +965,223 @@ mod tests { let first = table.refresh_column("maybe").await.unwrap(); assert_eq!(first.rows_filled, 0); - assert_eq!(first.version, declared); + assert!(first.version > declared); assert_eq!(read(&table, "maybe").await, vec![None, None, None]); let again = table.refresh_column("maybe").await.unwrap(); assert_eq!(again.rows_filled, 0); - assert_eq!(again.version, declared); + assert_eq!(again.version, first.version); } - /// The contract's boundary: a filled fragment is not revisited, so - /// mutating an input leaves the value computed at fill time. + /// A filled row whose input moved is recomputed: the update rewrites + /// the row into a fragment the stamp never signed, and only that one. #[tokio::test] - async fn test_refresh_does_not_observe_input_mutation() { - let table = table_with("refresh_mutation", vec![1]).await; + async fn test_refresh_recomputes_a_row_whose_input_moved() { + let table = table_with("refresh_mutation", vec![1, 2]).await; + declare_doubled(&table).await.unwrap(); + append(&table, vec![5]).await; + table.refresh_column("doubled").await.unwrap(); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(10)] + ); + + table + .update() + .column("x", "7") + .only_if("x = 5") + .execute() + .await + .unwrap(); + + let again = table.refresh_column("doubled").await.unwrap(); + assert_eq!(again.rows_filled, 1); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(14)] + ); + + let settled = table.refresh_column("doubled").await.unwrap(); + assert_eq!(settled.rows_filled, 0); + assert_eq!(settled.version, again.version); + } + + /// Each stamp is a sidecar under `_computed/`; pruning old versions + /// removes the sidecars only they referenced, and keeps the current one. + #[tokio::test] + async fn test_pruning_drops_the_sidecars_of_pruned_versions() { + let dir = tempfile::tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let batch = record_batch!(("x", Int32, [1, 2])).unwrap(); + let table = conn + .create_table("sidecars", batch) + .execute() + .await + .unwrap(); declare_doubled(&table).await.unwrap(); table.refresh_column("doubled").await.unwrap(); - assert_eq!(read(&table, "doubled").await, vec![Some(2)]); + append(&table, vec![5]).await; + table.refresh_column("doubled").await.unwrap(); + let sidecars = || { + std::fs::read_dir(dir.path().join("sidecars.lance").join("_computed")) + .unwrap() + .count() + }; + assert_eq!(sidecars(), 2); - table.update().column("x", "3").execute().await.unwrap(); + table + .optimize(crate::table::OptimizeAction::Prune { + older_than: Some(chrono::Duration::zero()), + delete_unverified: Some(true), + error_if_tagged_old_versions: None, + }) + .await + .unwrap(); + assert_eq!(sidecars(), 1); + assert_eq!( + table.refresh_column("doubled").await.unwrap().rows_filled, + 0 + ); + } + + /// Absolute timestamp pruning applies the same computed-column sidecar + /// cleanup as duration-based pruning. + #[tokio::test] + async fn test_absolute_pruning_drops_the_sidecars_of_pruned_versions() { + let dir = tempfile::tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let batch = record_batch!(("x", Int32, [1, 2])).unwrap(); + let table = conn + .create_table("sidecars", batch) + .execute() + .await + .unwrap(); + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + append(&table, vec![5]).await; + table.refresh_column("doubled").await.unwrap(); + let sidecars = || { + std::fs::read_dir(dir.path().join("sidecars.lance").join("_computed")) + .unwrap() + .count() + }; + assert_eq!(sidecars(), 2); + + table + .optimize_prune_before(chrono::Utc::now(), Some(true), None) + .await + .unwrap(); + assert_eq!(sidecars(), 1); + assert_eq!( + table.refresh_column("doubled").await.unwrap().rows_filled, + 0 + ); + } + + /// A deleted row is never computed and the rows that stay keep their + /// values: a delete recomputes nothing and stamps nothing. + #[tokio::test] + async fn test_a_delete_recomputes_nothing() { + let table = table_with("refresh_delete", vec![1, 2, 3]).await; + declare_doubled(&table).await.unwrap(); + let filled = table.refresh_column("doubled").await.unwrap(); + + table.delete("x = 2").await.unwrap(); + let deleted = table.version().await.unwrap(); let again = table.refresh_column("doubled").await.unwrap(); assert_eq!(again.rows_filled, 0); - assert_eq!(read(&table, "doubled").await, vec![Some(2)]); + assert_eq!(again.version, deleted); + assert!(deleted > filled.version); + assert_eq!(read(&table, "doubled").await, vec![Some(2), Some(6)]); + } + + /// Compaction copies inputs unchanged, so a fragment it builds from + /// signed ones is fresh: the refresh recomputes nothing and only records + /// the new fragment. + #[tokio::test] + async fn test_a_compaction_of_signed_fragments_recomputes_nothing() { + let table = table_with("refresh_compact_signed", vec![1, 2]).await; + declare_doubled(&table).await.unwrap(); + append(&table, vec![5]).await; + table.refresh_column("doubled").await.unwrap(); + + table + .optimize(crate::table::OptimizeAction::Compact { + options: crate::table::CompactionOptions::default(), + remap_options: None, + }) + .await + .unwrap(); + let compacted = table.version().await.unwrap(); + + let carried = table.refresh_column("doubled").await.unwrap(); + assert_eq!(carried.rows_filled, 0); + assert_eq!(carried.version, compacted + 1); + let settled = table.refresh_column("doubled").await.unwrap(); + assert_eq!(settled.version, carried.version); + assert_eq!( + read(&table, "doubled").await, + vec![Some(2), Some(4), Some(10)] + ); + } + + /// A column declared before signatures existed has no map. Its first + /// refresh keeps the null-fill contract and enrolls what it read from; + /// from then on a moved input is recomputed like any other. + #[tokio::test] + async fn test_an_unsigned_column_is_enrolled_by_its_first_refresh() { + let table = table_with("refresh_legacy", vec![1, 2]).await; + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + table + .update() + .column("x", "3") + .only_if("x = 1") + .execute() + .await + .unwrap(); + + let native = table.as_native().unwrap(); + let mut dataset = (*native.dataset.get().await.unwrap()).clone(); + let declaration = dataset + .schema() + .field("doubled") + .unwrap() + .metadata + .iter() + .filter(|(key, _)| !key.starts_with("computed_refresh.")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + dataset + .update_field_metadata() + .replace("doubled", declaration) + .unwrap() + .await + .unwrap(); + table.checkout_latest().await.unwrap(); + + // Null-fill only: the moved row keeps the value it was filled with. + let enrolled = table.refresh_column("doubled").await.unwrap(); + assert_eq!(enrolled.rows_filled, 0); + assert_eq!(read(&table, "doubled").await, vec![Some(2), Some(4)]); + + table + .update() + .column("x", "5") + .only_if("x = 3") + .execute() + .await + .unwrap(); + let again = table.refresh_column("doubled").await.unwrap(); + assert_eq!(again.rows_filled, 1); + assert_eq!(read(&table, "doubled").await, vec![Some(4), Some(10)]); } /// A row rewrite before the first refresh materializes the declared @@ -831,11 +1199,11 @@ mod tests { assert_eq!(read(&table, "doubled").await, vec![Some(6)]); } - /// The contract holds row by row, not fragment by fragment: revisiting a - /// fragment to fill one row must not recompute a filled row sitting beside - /// it, even where the input behind it has since changed. + /// A fragment compacted out of one the stamp never signed cannot vouch + /// for any of its rows: every live row is recomputed, the moved one + /// included. #[tokio::test] - async fn test_refresh_does_not_recompute_a_filled_row_beside_an_unfilled_one() { + async fn test_a_compaction_of_an_unsigned_fragment_recomputes_it() { let table = table_with("refresh_mixed", vec![1, 2]).await; declare_doubled(&table).await.unwrap(); table.refresh_column("doubled").await.unwrap(); @@ -857,18 +1225,70 @@ mod tests { .unwrap(); let result = table.refresh_column("doubled").await.unwrap(); - assert_eq!(result.rows_filled, 1); - // 2 is the mutated row keeping the value it was filled with, not 200. + assert_eq!(result.rows_filled, 3); + assert_eq!( + read(&table, "doubled").await, + vec![Some(4), Some(10), Some(200)] + ); + } + + /// The gate's reproducer: a raw lance append may carry a value for the + /// computed column. Compaction cannot certify it, so the product is + /// recomputed and the supplied value replaced. + #[tokio::test] + async fn test_raw_append_values_are_not_trusted_after_compaction() { + use arrow_array::RecordBatchIterator; + use lance::Dataset; + use lance::dataset::{WriteMode, WriteParams}; + + let dir = tempfile::tempdir().unwrap(); + let conn = connect(dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let batch = record_batch!(("x", Int32, [1, 2])).unwrap(); + let table = conn + .create_table("raw_append", batch) + .execute() + .await + .unwrap(); + declare_doubled(&table).await.unwrap(); + table.refresh_column("doubled").await.unwrap(); + + let batch = record_batch!(("x", Int32, [5]), ("doubled", Int32, [Some(999_i32)])).unwrap(); + let schema = batch.schema(); + let uri = table.uri().await.unwrap(); + Dataset::write( + RecordBatchIterator::new(vec![Ok(batch)], schema), + &uri, + Some(WriteParams { + mode: WriteMode::Append, + ..Default::default() + }), + ) + .await + .unwrap(); + table.checkout_latest().await.unwrap(); + table + .optimize(crate::table::OptimizeAction::Compact { + options: crate::table::CompactionOptions::default(), + remap_options: None, + }) + .await + .unwrap(); + + let result = table.refresh_column("doubled").await.unwrap(); + assert_eq!(result.rows_filled, 3); assert_eq!( read(&table, "doubled").await, vec![Some(2), Some(4), Some(10)] ); } - /// Filling a fragment must not disturb the values it already holds, which - /// is what makes a compaction-mixed fragment safe to revisit. + /// An appended fragment holds no values, so compacting it into a signed + /// one leaves the product fresh: only the appended rows are filled. #[tokio::test] - async fn test_refresh_preserves_already_filled_rows() { + async fn test_a_compaction_with_an_appended_fragment_fills_only_its_rows() { let table = table_with("refresh_preserves", vec![1, 2]).await; declare_doubled(&table).await.unwrap(); table.refresh_column("doubled").await.unwrap(); @@ -995,7 +1415,8 @@ mod tests { assert_eq!(result.rows_failed, 0); assert_eq!(result.rows_remaining, 0); assert_eq!(result.source_version, 2); - assert_eq!(result.published_version, Some(3)); + // The fill lands at 3; the stamp recording its inputs is published at 4. + assert_eq!(result.published_version, Some(4)); assert_eq!(job.status().await.unwrap(), "finished"); assert_eq!( read(&table, "doubled").await, @@ -1059,31 +1480,32 @@ mod tests { assert_eq!(read(&table, "quotient").await, vec![Some(10)]); } - /// The gate's reproducer: an already-filled row's value must not be - /// re-evaluated either -- its input may have mutated into one the - /// expression chokes on. + /// A filled row whose input moved is re-evaluated, and a row whose + /// input did not move is not: the untouched fragment is never read, so + /// its poison input is never reached. #[tokio::test] - async fn test_a_filled_rows_value_is_never_evaluated() { - let table = table_with("refresh_filled_poison", vec![1, 2]).await; + async fn test_only_a_moved_rows_value_is_re_evaluated() { + let table = table_with("refresh_filled_poison", vec![1, 0]).await; table .add_columns() - .computed("quotient", "10 / x") + .computed("quotient", "10 / coalesce(nullif(x, 0), 1)") .execute() .await .unwrap(); table.refresh_column("quotient").await.unwrap(); + assert_eq!(read(&table, "quotient").await, vec![Some(10), Some(10)]); + append(&table, vec![5]).await; table .update() - .column("x", "0") + .column("x", "2") .only_if("x = 1") .execute() .await .unwrap(); - append(&table, vec![5]).await; let result = table.refresh_column("quotient").await.unwrap(); - assert_eq!(result.rows_filled, 1); + assert_eq!(result.rows_filled, 2); assert_eq!( read(&table, "quotient").await, vec![Some(2), Some(5), Some(10)] diff --git a/rust/lancedb/src/utils/mod.rs b/rust/lancedb/src/utils/mod.rs index 07d1836a1..f80cc876a 100644 --- a/rust/lancedb/src/utils/mod.rs +++ b/rust/lancedb/src/utils/mod.rs @@ -19,10 +19,15 @@ use std::pin::Pin; use crate::error::{Error, Result}; use datafusion_physical_plan::SendableRecordBatchStream; -static TABLE_NAME_REGEX: std::sync::LazyLock = - std::sync::LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9_\-\.]+$").unwrap()); -static NAMESPACE_NAME_REGEX: std::sync::LazyLock = - std::sync::LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9_\-\.]+$").unwrap()); +/// The characters any object name may contain: a table, a namespace segment, a +/// Secret, a materialized view. +/// +/// No positional rule on top of it -- a name may begin with `_`, `-` or `.`, +/// as LanceDB namespaces already do. `.` and `..` are excluded separately, by +/// [`reject_relative_segment`]: that is a property of where a name sits in a +/// URL, not of the name. Length is the service's to bound. +static OBJECT_NAME_REGEX: std::sync::LazyLock = + std::sync::LazyLock::new(|| regex::Regex::new(r"^[A-Za-z0-9_.\-]+$").unwrap()); pub trait PatchStoreParam { fn patch_with_store_wrapper( @@ -81,54 +86,94 @@ impl PatchReadParam for ReadParams { } } -/// Validate table name. -pub fn validate_table_name(name: &str) -> Result<()> { - if name.is_empty() { - return Err(Error::InvalidTableName { - name: name.to_string(), - reason: "Table names cannot be empty strings".to_string(), - }); - } - if !TABLE_NAME_REGEX.is_match(name) { - return Err(Error::InvalidTableName { - name: name.to_string(), - reason: - "Table names can only contain alphanumeric characters, underscores, hyphens, and periods" - .to_string(), +/// The reason `.` and `..` are refused wherever a name becomes a path segment. +const RELATIVE_SEGMENT_REASON: &str = + "'.' and '..' are read as relative path segments and cannot address an object"; + +/// Whether URL parsing would resolve this component away rather than keep it. +/// +/// Exactly `.` and `..`, and their percent-encoded spellings -- resolution +/// happens after decoding, so `%2E%2E` collapses as surely as `..` does, and +/// `drop_table("..")` would reach `/v1/drop/`. No wider than that: `...` is an +/// ordinary segment that addresses fine. +fn is_relative_segment(value: &str) -> bool { + let decoded = value.replace("%2e", ".").replace("%2E", "."); + decoded == "." || decoded == ".." +} + +/// Refuse a path component that URL parsing resolves as a relative segment. +/// +/// Reachable on its own for an identifier with no other validator: a Function +/// name has no client-side grammar, so this is the only rule that applies. +pub(crate) fn reject_relative_segment(what: &str, value: &str) -> Result<()> { + if is_relative_segment(value) { + return Err(Error::InvalidInput { + message: format!("invalid {what} '{value}': {RELATIVE_SEGMENT_REASON}"), }); } Ok(()) } -/// Validate a namespace name component +/// Every rule an object name obeys: non-empty, inside [`OBJECT_NAME_REGEX`], +/// and addressable as a path segment. /// -/// Namespace names must: -/// - Not be empty -/// - Only contain alphanumeric characters, underscores, hyphens, and periods -/// -/// # Arguments -/// * `name` - A single namespace component (not the full path) -/// -/// # Returns -/// * `Ok(())` if the namespace name is valid -/// * `Err(Error)` if the namespace name is invalid -pub fn validate_namespace_name(name: &str) -> Result<()> { +/// Returns the reason rather than an [`Error`], because the error type is each +/// API's own -- a table reports [`Error::InvalidTableName`], the rest +/// [`Error::InvalidInput`]. Sharing the rules but not the error keeps a table, +/// a namespace segment and a Secret from drifting apart. +fn check_object_name(name: &str) -> std::result::Result<(), &'static str> { if name.is_empty() { - return Err(Error::InvalidInput { - message: "Namespace names cannot be empty strings".to_string(), - }); + return Err("it must not be empty"); } - if !NAMESPACE_NAME_REGEX.is_match(name) { - return Err(Error::InvalidInput { - message: format!( - "Invalid namespace name '{}': Namespace names can only contain alphanumeric characters, underscores, hyphens, and periods", - name - ), - }); + if !OBJECT_NAME_REGEX.is_match(name) { + return Err( + "it may contain only alphanumeric characters, underscores, hyphens and periods", + ); + } + if is_relative_segment(name) { + return Err(RELATIVE_SEGMENT_REASON); } Ok(()) } +/// Validate a table name. +pub fn validate_table_name(name: &str) -> Result<()> { + check_object_name(name).map_err(|reason| Error::InvalidTableName { + name: name.to_string(), + reason: reason.to_string(), + }) +} + +/// Validate one component of a namespace path -- a single segment, not the +/// whole path. [`validate_namespace`] covers a path. +pub fn validate_namespace_name(name: &str) -> Result<()> { + check_object_name(name).map_err(|reason| Error::InvalidInput { + message: format!("invalid namespace name '{name}': {reason}"), + }) +} + +/// Validate one component of a Secret identifier: a Secret name, or one segment +/// of the namespace path holding it. +/// +/// The join decides identity, and the service only sees what the split +/// produced. `"a$b"` is not a name the service accepts, but joined and split it +/// reads as the namespace `a` and the name `b` -- a different Secret that may +/// already exist. This is not a second opinion on the name; it is what lets the +/// service have one. +pub fn validate_secret_component(what: &str, value: &str) -> Result<()> { + check_object_name(value).map_err(|reason| Error::InvalidInput { + message: format!("invalid {what} '{value}': {reason}"), + }) +} + +/// Validate a Secret name and every segment of the namespace path holding it. +pub fn validate_secret_reference(name: &str, namespace_path: &[String]) -> Result<()> { + for segment in namespace_path { + validate_secret_component("Secret namespace path segment", segment)?; + } + validate_secret_component("Secret name", name) +} + /// Validate all components of a namespace /// /// Iterates through all namespace components and validates each one. @@ -227,7 +272,7 @@ pub(crate) fn resolve_arrow_field_path(schema: &Schema, column: &str) -> Result< pub(crate) struct ResolvedFtsField { pub canonical_path: String, - pub field: Field, + pub terminal_field: Field, pub list_depth: usize, } @@ -309,7 +354,7 @@ pub(crate) fn resolve_lance_fts_field_path( ); Ok(ResolvedFtsField { canonical_path, - field: Field::from(field), + terminal_field: Field::from(terminal), list_depth, }) } @@ -375,7 +420,7 @@ pub(crate) fn resolve_arrow_fts_field_path( message: format!("Invalid schema: {}", e), })?; let resolved = resolve_lance_fts_field_path(&lance_schema, column)?; - Ok((resolved.canonical_path, resolved.field)) + Ok((resolved.canonical_path, resolved.terminal_field)) } pub fn supported_btree_data_type(dtype: &DataType) -> bool { @@ -394,6 +439,14 @@ pub fn supported_btree_data_type(dtype: &DataType) -> bool { ) } +pub fn supported_zonemap_data_type(dtype: &DataType) -> bool { + supported_btree_data_type(dtype) + || matches!( + dtype, + DataType::LargeUtf8 | DataType::Binary | DataType::LargeBinary + ) +} + pub fn supported_bitmap_data_type(dtype: &DataType) -> bool { dtype.is_integer() || matches!( @@ -647,8 +700,9 @@ mod tests { Field::new("docs", text_list(), true), ]); - let (path, _) = resolve_arrow_fts_field_path(&schema, "docs.content").unwrap(); + let (path, field) = resolve_arrow_fts_field_path(&schema, "docs.content").unwrap(); assert_eq!(path, "docs.content"); + assert_eq!(field.data_type(), &DataType::Utf8); let lance_schema = lance_core::datatypes::Schema::try_from(&schema).unwrap(); let field_id = lance_schema @@ -803,8 +857,11 @@ mod tests { assert!(validate_table_name("_12345table").is_ok()); assert!(validate_table_name("table.12345").is_ok()); assert!(validate_table_name("table.._dot_..12345").is_ok()); + assert!(validate_table_name("...").is_ok()); assert!(validate_table_name("").is_err()); + assert!(validate_table_name(".").is_err()); + assert!(validate_table_name("..").is_err()); assert!(validate_table_name("my_table!").is_err()); assert!(validate_table_name("my/table").is_err()); assert!(validate_table_name("my@table").is_err()); diff --git a/rust/lancedb/tests/blob_integration.rs b/rust/lancedb/tests/blob_integration.rs index 7b709b645..dd4051657 100644 --- a/rust/lancedb/tests/blob_integration.rs +++ b/rust/lancedb/tests/blob_integration.rs @@ -19,6 +19,7 @@ use lancedb::{ connect, connect_namespace, database::listing::{ ListingDatabaseOptions, NewTableConfig, OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, + OPT_NEW_TABLE_STORAGE_VERSION, }, query::{ExecutableQuery, QueryBase}, table::{AddDataMode, CompactionOptions, OptimizeAction, OptimizeStats, WriteOptions}, @@ -46,6 +47,21 @@ fn binary_input_batch(ids: &[i64], payloads: &[Option<&[u8]>]) -> RecordBatch { .unwrap() } +/// What pyarrow infers for a batch of dicts whose blob values are all `None`. +fn null_typed_input_batch(ids: &[i64]) -> RecordBatch { + RecordBatch::try_new( + Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("image", DataType::Null, true), + ])), + vec![ + Arc::new(Int64Array::from(ids.to_vec())), + new_null_array(&DataType::Null, ids.len()), + ], + ) + .unwrap() +} + async fn create_inline_blob_table( db: &Connection, name: &str, @@ -111,7 +127,7 @@ async fn query_image_struct(table: &Table) -> StructArray { } #[tokio::test] -async fn declaring_blob_column_bumps_format_and_enables_stable_row_ids() -> Result<()> { +async fn declaring_blob_column_uses_v2_2_and_default_row_ids() -> Result<()> { let tmp = tempdir().unwrap(); let db = connect(tmp.path().to_str().unwrap()).execute().await?; let table = db @@ -120,12 +136,12 @@ async fn declaring_blob_column_bumps_format_and_enables_stable_row_ids() -> Resu .await?; assert!(supports_blob_v2(storage_format_version(&table).await)); - assert!(uses_stable_row_ids(&table).await); + assert!(!uses_stable_row_ids(&table).await); Ok(()) } #[tokio::test] -async fn explicit_stable_row_id_setting_wins_over_blob_default() -> Result<()> { +async fn blob_create_honors_disabled_stable_row_ids() -> Result<()> { let tmp = tempdir().unwrap(); let db = connect(tmp.path().to_str().unwrap()).execute().await?; let table = db @@ -146,7 +162,10 @@ async fn non_blob_table_keeps_default_format_and_row_id_setting() -> Result<()> let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])); let table = db.create_empty_table("t", schema).execute().await?; - assert!(!supports_blob_v2(storage_format_version(&table).await)); + assert_eq!( + storage_format_version(&table).await, + LanceFileVersion::Stable.resolve() + ); assert!(!uses_stable_row_ids(&table).await); Ok(()) } @@ -179,7 +198,7 @@ async fn creating_with_blob_data_bumps_format() -> Result<()> { let table = db.create_table("t", batch).execute().await?; assert!(supports_blob_v2(storage_format_version(&table).await)); - assert!(uses_stable_row_ids(&table).await); + assert!(!uses_stable_row_ids(&table).await); assert_eq!(table.count_rows(None).await?, 1); Ok(()) } @@ -251,6 +270,38 @@ async fn add_accepts_null_blob_rows() -> Result<()> { Ok(()) } +/// A batch whose blob values are all null carries no type information — pyarrow infers +/// `DataType::Null` for it, which is what a row-at-a-time insert of an optional blob column +/// looks like. Such a batch must be accepted, both as the first write and after bytes have +/// already been written. +#[tokio::test] +async fn add_accepts_all_null_typed_blob_column() -> Result<()> { + let tmp = tempdir().unwrap(); + let db = connect(tmp.path().to_str().unwrap()).execute().await?; + let table = db + .create_empty_table("t", blob_table_schema()) + .execute() + .await?; + + table.add(null_typed_input_batch(&[1])).execute().await?; + assert_eq!(table.count_rows(None).await?, 1); + assert!(query_image_struct(&table).await.is_null(0)); + + table + .add(binary_input_batch(&[2], &[Some(b"bytes".as_slice())])) + .execute() + .await?; + table.add(null_typed_input_batch(&[3])).execute().await?; + assert_eq!(table.count_rows(None).await?, 3); + + let row_ids = collect_row_ids(&table).await?; + let bytes = table.fetch_blobs("image", &row_ids).await?; + assert!(bytes.is_null(0)); + assert_eq!(bytes.value(1), b"bytes"); + assert!(bytes.is_null(2)); + Ok(()) +} + #[tokio::test] async fn add_rejects_uncoercible_blob_input() -> Result<()> { let tmp = tempdir().unwrap(); @@ -277,7 +328,7 @@ async fn add_rejects_uncoercible_blob_input() -> Result<()> { } #[tokio::test] -async fn connection_level_stable_row_id_setting_wins_over_blob_default() -> Result<()> { +async fn connection_disables_stable_row_ids_on_blob_create() -> Result<()> { let tmp = tempdir().unwrap(); let db = connect(tmp.path().to_str().unwrap()) .storage_option(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, "false") @@ -294,7 +345,7 @@ async fn connection_level_stable_row_id_setting_wins_over_blob_default() -> Resu } #[tokio::test] -async fn namespace_create_applies_blob_defaults() -> Result<()> { +async fn namespace_blob_create_uses_v2_2_and_default_row_ids() -> Result<()> { let tmp = tempdir().unwrap(); let mut properties = std::collections::HashMap::new(); properties.insert("root".to_string(), tmp.path().to_str().unwrap().to_string()); @@ -304,6 +355,23 @@ async fn namespace_create_applies_blob_defaults() -> Result<()> { .execute() .await?; + assert!(supports_blob_v2(storage_format_version(&table).await)); + assert!(!uses_stable_row_ids(&table).await); + Ok(()) +} + +#[tokio::test] +async fn namespace_create_honors_enabled_stable_row_ids() -> Result<()> { + let tmp = tempdir().unwrap(); + let mut properties = std::collections::HashMap::new(); + properties.insert("root".to_string(), tmp.path().to_str().unwrap().to_string()); + let db = connect_namespace("dir", properties).execute().await?; + let table = db + .create_empty_table("t", blob_table_schema()) + .storage_option(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, "true") + .execute() + .await?; + assert!(supports_blob_v2(storage_format_version(&table).await)); assert!(uses_stable_row_ids(&table).await); Ok(()) @@ -430,6 +498,35 @@ async fn collect_id_rowid(table: &Table) -> Result> { .collect()) } +fn assert_missing_blob_row_ids(err: &Error) { + assert!(matches!(err, Error::InvalidInput { .. }), "got {err:?}"); + let message = err.to_string(); + assert!(message.contains("row ids"), "{message}"); + assert!(!message.contains("rowaddr"), "{message}"); + assert!(!message.contains("fragment"), "{message}"); +} + +async fn assert_fetch_apis_reject_missing_row_ids(table: &Table, row_ids: &[u64]) -> Result<()> { + let err = table.fetch_blobs("image", row_ids).await.unwrap_err(); + assert_missing_blob_row_ids(&err); + + let err = table.fetch_blob_files("image", row_ids).await.unwrap_err(); + assert_missing_blob_row_ids(&err); + + let err = table + .fetch_blob_ranges( + "image", + row_ids + .iter() + .copied() + .map(|row_id| BlobRangeRequest::new(row_id, 0, 1)), + ) + .await + .unwrap_err(); + assert_missing_blob_row_ids(&err); + Ok(()) +} + #[tokio::test] async fn fetch_blobs_round_trips_bytes() -> Result<()> { let tmp = tempdir().unwrap(); @@ -482,7 +579,7 @@ async fn fetch_blobs_round_trips_nested_blob_column() -> Result<()> { let table = db.create_table("t", batch).execute().await?; assert!(supports_blob_v2(storage_format_version(&table).await)); - assert!(uses_stable_row_ids(&table).await); + assert!(!uses_stable_row_ids(&table).await); let ids = collect_row_ids(&table).await?; let bytes = table.fetch_blobs("info.blob", &ids).await?; @@ -656,8 +753,7 @@ async fn fetch_blob_ranges_validates_requests() -> Result<()> { .fetch_blob_ranges("image", [BlobRangeRequest::new(u64::MAX, 0, 1)]) .await .unwrap_err(); - assert!(matches!(&err, Error::InvalidInput { .. }), "got {err:?}"); - assert!(err.to_string().contains("row IDs")); + assert_missing_blob_row_ids(&err); Ok(()) } @@ -690,7 +786,21 @@ async fn fetch_blobs_out_of_range_id_errors_without_panic() -> Result<()> { let table = create_inline_blob_table(&db, "t", &[1], &[Some(b"x".as_slice())]).await?; let err = table.fetch_blobs("image", &[u64::MAX]).await.unwrap_err(); - assert!(err.to_string().contains("row IDs")); + assert_missing_blob_row_ids(&err); + Ok(()) +} + +#[tokio::test] +async fn fetch_blob_files_rejects_missing_fragment_row_addr() -> Result<()> { + let tmp = tempdir().unwrap(); + let db = connect(tmp.path().to_str().unwrap()).execute().await?; + let table = create_inline_blob_table(&db, "t", &[1], &[Some(b"x".as_slice())]).await?; + + let err = table + .fetch_blob_files("image", &[1u64 << 32]) + .await + .unwrap_err(); + assert_missing_blob_row_ids(&err); Ok(()) } @@ -700,24 +810,25 @@ async fn fetch_blob_apis_reject_mixed_valid_and_missing_row_ids() -> Result<()> let db = connect(tmp.path().to_str().unwrap()).execute().await?; let table = create_inline_blob_table(&db, "t", &[1], &[Some(b"x".as_slice())]).await?; let row_id = collect_row_ids(&table).await?[0]; - let row_ids = [u64::MAX, row_id]; + let missing_row_addr = 1u64 << 32; + let row_ids = [missing_row_addr, row_id]; + assert_fetch_apis_reject_missing_row_ids(&table, &row_ids).await +} - let err = table.fetch_blobs("image", &row_ids).await.unwrap_err(); - assert!(matches!(&err, Error::InvalidInput { .. }), "got {err:?}"); - assert!(err.to_string().contains("row IDs")); +#[tokio::test] +async fn fetch_blob_apis_reject_deleted_row_ids() -> Result<()> { + let tmp = tempdir().unwrap(); + let db = connect(tmp.path().to_str().unwrap()).execute().await?; + let table = + create_inline_blob_table(&db, "t", &[1, 2], &[Some(b"one".as_slice()), Some(b"two")]) + .await?; + let pairs = collect_id_rowid(&table).await?; + let deleted_row_addr = pairs.iter().find(|(id, _)| *id == 2).unwrap().1; + let live_row_addr = pairs.iter().find(|(id, _)| *id == 1).unwrap().1; - let err = table.fetch_blob_files("image", &row_ids).await.unwrap_err(); - assert!(matches!(&err, Error::InvalidInput { .. }), "got {err:?}"); - assert!(err.to_string().contains("row IDs")); + table.delete("id = 2").await?; - let requests = row_ids.map(|row_id| BlobRangeRequest::new(row_id, 0, 1)); - let err = table - .fetch_blob_ranges("image", requests) - .await - .unwrap_err(); - assert!(matches!(&err, Error::InvalidInput { .. }), "got {err:?}"); - assert!(err.to_string().contains("row IDs")); - Ok(()) + assert_fetch_apis_reject_missing_row_ids(&table, &[deleted_row_addr, live_row_addr]).await } #[tokio::test] @@ -749,7 +860,11 @@ async fn fetch_blobs_rejects_unknown_column() -> Result<()> { #[tokio::test] async fn fetch_blobs_rejects_legacy_v1_blob_column() -> Result<()> { let tmp = tempdir().unwrap(); - let db = connect(tmp.path().to_str().unwrap()).execute().await?; + // Legacy v1 blob columns are only writable at file version <= 2.1. + let db = connect(tmp.path().to_str().unwrap()) + .storage_options([(OPT_NEW_TABLE_STORAGE_VERSION, "2.1")]) + .execute() + .await?; let legacy = Field::new("image", DataType::LargeBinary, true).with_metadata( std::collections::HashMap::from([("lance-encoding:blob".to_string(), "true".to_string())]), ); @@ -920,7 +1035,10 @@ async fn fetch_blobs_after_delete() -> Result<()> { #[tokio::test] async fn fetch_blobs_with_precompaction_row_ids_survives_compaction() -> Result<()> { let tmp = tempdir().unwrap(); - let db = connect(tmp.path().to_str().unwrap()).execute().await?; + let db = connect(tmp.path().to_str().unwrap()) + .storage_option(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, "true") + .execute() + .await?; let table = create_inline_blob_table(&db, "t", &[1], &[Some(b"frag-one".as_slice())]).await?; table .add(binary_input_batch(&[2], &[Some(b"frag-two".as_slice())])) diff --git a/rust/lancedb/tests/first_class_function_slice1.rs b/rust/lancedb/tests/first_class_function_slice1.rs index ce020bd53..3971a2471 100644 --- a/rust/lancedb/tests/first_class_function_slice1.rs +++ b/rust/lancedb/tests/first_class_function_slice1.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use lancedb::function::{ FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, }; +use lancedb::secrets::{SecretBinding, SecretReference}; use serde_json::Value; fn fixture(name: &str) -> String { @@ -20,14 +21,41 @@ fn job_result(name: &str) -> Value { serde_json::from_str::(&fixture(name)).expect("remote Job fixture")["result"].clone() } +/// No client value models a resolved credential, at any nesting depth. +fn assert_no_secret_values(value: &Value) { + match value { + Value::Object(values) => { + for (key, value) in values { + assert!( + !matches!( + key.as_str(), + "secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets" + ), + "client canonical value must not model resolved secret material" + ); + assert_no_secret_values(value); + } + } + Value::Array(values) => values.iter().for_each(assert_no_secret_values), + _ => {} + } +} + #[test] fn function_version_job_result_matches_shared_canonical_golden() { let result = job_result("remote_function_job.json"); let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result"); assert_eq!(version.name(), "embed"); - assert_eq!(version.version(), "fv_01K3EXACT"); - assert_eq!(version.runtime_digest(), "sha256:runtime"); + assert_eq!(version.version(), "1"); + assert_ne!(version.image().manifest_digest, version.version()); + assert_eq!( + version.secret_bindings(), + [SecretBinding::Env { + variable: "HF_TOKEN".to_string(), + secret_ref: SecretReference::new("hf-prod"), + }] + ); assert_eq!( version.to_canonical_json().expect("canonical JSON"), fixture("remote_function_version.canonical.json").trim() @@ -45,16 +73,28 @@ fn version_identity_is_immutable_and_exact() { assert_eq!(reopened.version(), version.version()); let mut changed = original; - changed["version"] = Value::String("fv_01K3DIFFERENT".to_string()); + changed["version"] = Value::String("2".to_string()); let changed = FunctionVersion::from_json(&changed.to_string()).expect("changed version"); assert_ne!(changed, version); + assert_eq!(changed.image(), version.image()); + for invalid in [ + version.image().manifest_digest.as_str(), + "0", + "01", + "-1", + "18446744073709551616", + ] { + let mut value = serde_json::to_value(&version).unwrap(); + value["version"] = Value::String(invalid.into()); + assert!(FunctionVersion::from_json(&value.to_string()).is_err()); + } } #[test] fn application_and_binding_match_shared_remote_goldens() { let application = FunctionApplication::from_json(&fixture("remote_function_application.json")) .expect("application fixture"); - assert_eq!(application.function().version, "fv_01K3TEXT"); + assert_eq!(application.function().version, "1"); assert_eq!(application.output().kind, "named_struct"); assert_eq!(application.inputs().len(), 2); assert_eq!( @@ -64,7 +104,7 @@ fn application_and_binding_match_shared_remote_goldens() { let binding = FunctionBinding::from_json(&fixture("remote_function_binding.json")) .expect("binding fixture"); - assert_eq!(binding.function().version, "fv_01K3TEXT"); + assert_eq!(binding.function().version, "1"); assert_eq!(binding.outputs()[0].output_ordinal, 0); assert_eq!(binding.outputs()[1].output_ordinal, 1); assert!(binding.input_schema().is_some()); @@ -113,22 +153,18 @@ fn refresh_job_result_matches_shared_canonical_golden() { fn unknown_fields_and_discriminators_are_forward_decodable() { let mut result = job_result("remote_function_job.json"); result["future_version_metadata"] = serde_json::json!({"retention_class": "catalog"}); - result["runtime"] = serde_json::json!({ - "kind": "wasm", - "module_digest": "sha256:wasm" - }); + result["image"]["descriptor"]["future_interface"] = serde_json::json!({"version": 2}); result["signature"]["output"]["kind"] = Value::String("future_output_shape".to_string()); let version = FunctionVersion::from_json(&result.to_string()).expect("future remote value"); - assert_eq!(version.runtime().kind(), "wasm"); - assert_eq!(version.runtime().python_version(), None); + assert_eq!(version.image().descriptor["future_interface"]["version"], 2); assert_eq!(version.signature().output.kind, "future_output_shape"); assert_eq!( serde_json::from_str::( &version.to_canonical_json().expect("canonical future value") ) - .expect("canonical JSON")["runtime"], - serde_json::json!({"kind": "wasm"}) + .expect("canonical JSON")["image"]["descriptor"]["future_interface"], + serde_json::json!({"version": 2}) ); } @@ -142,3 +178,75 @@ fn floating_point_application_literals_are_rejected_consistently() { .contains("floating-point Function literals") ); } + +#[test] +fn canonical_client_values_carry_bindings_and_no_credentials() { + let result = job_result("remote_function_job.json"); + let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result"); + let canonical: Value = serde_json::from_str( + &version + .to_canonical_json() + .expect("canonical FunctionVersion"), + ) + .expect("canonical JSON"); + + assert_eq!( + canonical["secret_bindings"], + serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}]) + ); + assert_no_secret_values(&canonical); +} + +/// A binding kind a newer server introduces must not fail the whole version. +/// +/// This is the cost the union pays for being one field: an unknown variant is +/// a decode error unless it is caught, so it is caught -- and the payload is +/// dropped rather than retained, as `PythonRuntimeSpec` does, because the +/// client does not proxy catalog values. +#[test] +fn an_unknown_binding_kind_is_forward_decodable() { + let mut result = job_result("remote_function_job.json"); + result["secret_bindings"] = serde_json::json!([ + {"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}, + {"kind": "file", "path": "/run/secrets/tok", "secret_ref": {"name": "hf-prod"}}, + ]); + + let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind"); + + let kinds = version + .secret_bindings() + .iter() + .map(|binding| binding.kind()) + .collect::>(); + assert_eq!(kinds, ["env", "file"]); + assert_eq!(version.secret_bindings()[1].variable(), None); + assert_eq!(version.secret_bindings()[1].secret(), None); + + // The unknown kind round-trips as its discriminator and nothing more. + let canonical: Value = + serde_json::from_str(&version.to_canonical_json().expect("canonical")).expect("JSON"); + assert_eq!( + canonical["secret_bindings"][1], + serde_json::json!({"kind": "file"}) + ); +} + +/// A Function that binds nothing carries no `secret_bindings` key: absent +/// decodes as an empty list, and an empty list serializes back to absent. +#[test] +fn a_version_without_bindings_omits_the_field_in_both_directions() { + let mut result = job_result("remote_function_job.json"); + result + .as_object_mut() + .expect("Function version object") + .remove("secret_bindings"); + let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result"); + + assert!(version.secret_bindings().is_empty()); + assert!( + !version + .to_canonical_json() + .expect("canonical FunctionVersion") + .contains("secret_bindings") + ); +} diff --git a/rust/lancedb/tests/first_class_function_slice2.rs b/rust/lancedb/tests/first_class_function_slice2.rs index 93252dde4..14ceb511d 100644 --- a/rust/lancedb/tests/first_class_function_slice2.rs +++ b/rust/lancedb/tests/first_class_function_slice2.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use lancedb::Error; use lancedb::function::FunctionRegistrationRequest; +use lancedb::secrets::{SecretBinding, SecretReference}; +use serde_json::Value; fn fixture(name: &str) -> String { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -14,6 +16,26 @@ fn fixture(name: &str) -> String { fs::read_to_string(path).expect("fixture must be readable") } +/// A registration request never models a resolved credential, at any depth. +fn assert_no_secret_values(value: &Value) { + match value { + Value::Object(values) => { + for (key, value) in values { + assert!( + !matches!( + key.as_str(), + "secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets" + ), + "registration requests must not model resolved secret material" + ); + assert_no_secret_values(value); + } + } + Value::Array(values) => values.iter().for_each(assert_no_secret_values), + _ => {} + } +} + #[test] fn registration_request_matches_shared_canonical_golden() { let request = FunctionRegistrationRequest::from_json(&fixture( @@ -22,10 +44,45 @@ fn registration_request_matches_shared_canonical_golden() { .expect("registration request"); assert_eq!(request.name, "normalize_score"); assert_eq!(request.artifact.adapter.kind, "scalar_to_arrow_batch"); + // The unchanged path: a Function that binds nothing serializes today's + // bytes, with no `secret_bindings` key at all. + assert!(request.secret_bindings.is_empty()); assert_eq!( request.to_canonical_json().expect("canonical request"), fixture("remote_function_registration_request.canonical.json").trim() ); + + let value: Value = + serde_json::from_str(&request.to_canonical_json().expect("canonical request")) + .expect("request JSON"); + assert_no_secret_values(&value); +} + +/// The same shared golden as the Python suite builds from `@udf(secrets=...)` +/// plus `bind_secrets`, so both clients agree byte for byte on a bound request. +#[test] +fn secret_bound_registration_request_matches_shared_canonical_golden() { + let request = FunctionRegistrationRequest::from_json(&fixture( + "remote_function_secret_registration_request.json", + )) + .expect("registration request"); + assert_eq!(request.name, "analyze_caption"); + assert_eq!( + request.secret_bindings, + [SecretBinding::Env { + variable: "OPENAI_API_KEY".to_string(), + secret_ref: SecretReference::new("openai-prod"), + }] + ); + assert_eq!( + request.to_canonical_json().expect("canonical request"), + fixture("remote_function_secret_registration_request.canonical.json").trim() + ); + + let value: Value = + serde_json::from_str(&request.to_canonical_json().expect("canonical request")) + .expect("request JSON"); + assert_no_secret_values(&value); } #[tokio::test] @@ -42,10 +99,14 @@ async fn local_function_catalog_operations_return_stable_not_supported() { let create_error = connection.create_function_async(request).await.unwrap_err(); let lookup_error = connection - .get_function("normalize_score", "fv_exact") + .get_function("normalize_score", "1") .await .unwrap_err(); - for error in [create_error, lookup_error] { + let drop_error = connection + .drop_function("normalize_score", "1") + .await + .unwrap_err(); + for error in [create_error, lookup_error, drop_error] { assert!(matches!( error, Error::NotSupported { message } diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/arrow_types.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/arrow_types.json index ff26e4c08..38d17821b 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/arrow_types.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/arrow_types.json @@ -78,6 +78,12 @@ "type": "utf8" } }, + { + "arrow_type": "large_utf8", + "json": { + "type": "large_utf8" + } + }, { "arrow_type": "binary", "json": { @@ -171,6 +177,21 @@ ] } }, + { + "arrow_type": "list", + "json": { + "type": "list", + "fields": [ + { + "name": "item", + "nullable": false, + "type": { + "type": "large_utf8" + } + } + ] + } + }, { "arrow_type": "large_list", "json": { @@ -186,6 +207,21 @@ ] } }, + { + "arrow_type": "large_list", + "json": { + "type": "large_list", + "fields": [ + { + "name": "item", + "nullable": false, + "type": { + "type": "large_utf8" + } + } + ] + } + }, { "arrow_type": "fixed_size_list", "json": { @@ -330,4 +366,4 @@ "timestamp[us]", "struct" ] -} \ No newline at end of file +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json index fd3944412..1c2374fba 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_fixed_size_declaration_request.json @@ -9,7 +9,7 @@ "application": { "function": { "name": "embed", - "version": "fv_01K3EXACT" + "version": "1", "object_id": "fixture", "location": "memory:///fixture", "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6" }, "inputs": [ { diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.canonical.json index b91dd1061..8efa681aa 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.canonical.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.canonical.json @@ -1 +1 @@ -{"columns":{"normalized_text":"search_text","token_count":"search_token_count"},"function":{"name":"text_features","version":"fv_01K3TEXT"},"inputs":[{"kind":"column","parameter":"title","value":{"path":"title"}},{"kind":"column","parameter":"body","value":{"path":"body"}}],"output":{"fields":[{"arrow_type":"utf8","name":"normalized_text","nullable":false},{"arrow_type":"int64","name":"token_count","nullable":false}],"kind":"named_struct"}} +{"columns":{"normalized_text":"search_text","token_count":"search_token_count"},"function":{"location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6","name":"text_features","object_id":"fixture","version":"1"},"inputs":[{"kind":"column","parameter":"title","value":{"path":"title"}},{"kind":"column","parameter":"body","value":{"path":"body"}}],"output":{"fields":[{"arrow_type":"utf8","name":"normalized_text","nullable":false},{"arrow_type":"int64","name":"token_count","nullable":false}],"kind":"named_struct"}} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.json index 177821aaa..d5b4fd5d8 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application.json @@ -1,5 +1,5 @@ { - "function": {"name": "text_features", "version": "fv_01K3TEXT"}, + "function": {"name": "text_features", "version": "1", "object_id": "fixture", "location": "memory:///fixture", "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs": [ {"parameter": "title", "kind": "column", "value": {"path": "title"}}, {"parameter": "body", "kind": "column", "value": {"path": "body"}} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application_float.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application_float.json index c23c8f3ca..98dafcfab 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application_float.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_application_float.json @@ -1,5 +1,5 @@ { - "function": {"name": "score", "version": "fv_01K3FLOAT"}, + "function": {"name": "score", "version": "1", "object_id": "fixture", "location": "memory:///fixture", "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs": [ {"parameter": "threshold", "kind": "literal", "value": 1e-7} ], diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.canonical.json index 143190f78..f1355364a 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.canonical.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.canonical.json @@ -1 +1 @@ -{"binding_id":"fb_01K3TEXT","function":{"name":"text_features","version":"fv_01K3TEXT"},"input_schema":{"fields":[{"name":"title","nullable":true,"type":{"type":"utf8"}},{"name":"body","nullable":true,"type":{"type":"utf8"}}]},"inputs":[{"arrow_type":"utf8","field_id":11,"field_path":"title","nullable":true,"parameter":"title"},{"arrow_type":"utf8","field_id":12,"field_path":"body","nullable":true,"parameter":"body"}],"output_schema":{"fields":[{"name":"search_text","nullable":true,"type":{"type":"utf8"}},{"name":"search_token_count","nullable":true,"type":{"type":"int64"}}]},"outputs":[{"arrow_type":"utf8","nullable":false,"output_field_id":21,"output_name":"search_text","output_ordinal":0,"result_field":"normalized_text"},{"arrow_type":"int64","nullable":false,"output_field_id":22,"output_name":"search_token_count","output_ordinal":1,"result_field":"token_count"}]} +{"binding_id":"fb_01K3TEXT","function":{"location":"memory:///fixture","manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6","name":"text_features","object_id":"fixture","version":"1"},"input_schema":{"fields":[{"name":"title","nullable":true,"type":{"type":"utf8"}},{"name":"body","nullable":true,"type":{"type":"utf8"}}]},"inputs":[{"arrow_type":"utf8","field_id":11,"field_path":"title","nullable":true,"parameter":"title"},{"arrow_type":"utf8","field_id":12,"field_path":"body","nullable":true,"parameter":"body"}],"output_schema":{"fields":[{"name":"search_text","nullable":true,"type":{"type":"utf8"}},{"name":"search_token_count","nullable":true,"type":{"type":"int64"}}]},"outputs":[{"arrow_type":"utf8","nullable":false,"output_field_id":21,"output_name":"search_text","output_ordinal":0,"result_field":"normalized_text"},{"arrow_type":"int64","nullable":false,"output_field_id":22,"output_name":"search_token_count","output_ordinal":1,"result_field":"token_count"}]} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.json index dec3fa3f8..ae1666544 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_binding.json @@ -1,6 +1,6 @@ { "binding_id": "fb_01K3TEXT", - "function": {"name": "text_features", "version": "fv_01K3TEXT"}, + "function": {"name": "text_features", "version": "1", "object_id": "fixture", "location": "memory:///fixture", "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs": [ {"parameter": "title", "field_id": 11, "field_path": "title", "arrow_type": "utf8", "nullable": true}, {"parameter": "body", "field_id": 12, "field_path": "body", "arrow_type": "utf8", "nullable": true} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json index 39a279692..c6bbd83e3 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json @@ -3,28 +3,73 @@ "job_type": "create_function", "job_state": "DONE", "creation_ms": 1787270400000, - "spec": {"name": "embed"}, + "spec": { + "name": "embed" + }, "result": { "name": "embed", - "version": "fv_01K3EXACT", - "artifact": { - "kind": "python_callable", - "digest": "sha256:code", - "entrypoint": "embed" - }, + "version": "1", "object_id": "fixture", "location": "memory:///fixture", "metadata": {}, "disabled": false, "signature": { - "inputs": [{"name": "text", "arrow_type": "utf8", "nullable": true}], - "output": {"kind": "scalar", "arrow_type": "list", "nullable": false} + "inputs": [ + { + "name": "text", + "arrow_type": "utf8", + "nullable": true + } + ], + "output": { + "kind": "scalar", + "arrow_type": "list", + "nullable": false + } }, - "runtime": { - "kind": "python", - "python_version": "3.12", - "environment": {"kind": "pip", "packages": ["sentence-transformers>=3"]}, - "env": {"TOKENIZERS_PARALLELISM": "false"} + "created_at": "2026-08-21T00:00:00Z", + "image": { + "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6", + "descriptor": { + "format_version": 1, + "python": { + "implementation": "cpython", + "version": "3.12.14", + "abi_tag": "cp312", + "executable": "/usr/local/bin/python3", + "import_paths": [ + "/opt/function/code" + ] + }, + "python_api": 1, + "entrypoint": "app.function:create", + "interface": { + "type": "lance.scalar", + "version": 1 + }, + "schemas": { + "input": "/opt/function/schemas/input.arrow", + "output": "/opt/function/schemas/output.arrow", + "initialization": "/opt/function/schemas/initialization.arrow" + }, + "requires": { + "kernel_min": "4.18.0", + "capabilities": [] + }, + "behavior": { + "result_stability": "input_and_context", + "side_effects": "non_idempotent" + } + }, + "source": false }, - "runtime_digest": "sha256:runtime", - "environment_digest": "sha256:environment", - "created_at": "2026-08-21T00:00:00Z" + "secret_bindings": [ + { + "kind": "env", + "variable": "HF_TOKEN", + "secret_ref": { + "name": "hf-prod" + } + } + ] }, - "future_job": {"trace_id": "trace-1"} + "future_job": { + "trace_id": "trace-1" + } } diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.canonical.json new file mode 100644 index 000000000..5cce05e9b --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.canonical.json @@ -0,0 +1 @@ +{"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK","encoding":"base64"},"digest":"sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077","entrypoint":"analyze_caption","kind":"python_callable"},"name":"analyze_caption","runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["openai==3.7.0"]},"kind":"python","python_version":"3.12"},"secret_bindings":[{"kind":"env","secret_ref":{"name":"openai-prod"},"variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.json new file mode 100644 index 000000000..6edea0a91 --- /dev/null +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.json @@ -0,0 +1,52 @@ +{ + "artifact": { + "adapter": { + "kind": "scalar_to_arrow_batch", + "version": 1 + }, + "content": { + "data": "ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK", + "encoding": "base64" + }, + "digest": "sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077", + "entrypoint": "analyze_caption", + "kind": "python_callable" + }, + "name": "analyze_caption", + "runtime": { + "env": { + "MODE": "test" + }, + "environment": { + "kind": "pip", + "packages": [ + "openai==3.7.0" + ] + }, + "kind": "python", + "python_version": "3.12" + }, + "signature": { + "inputs": [ + { + "arrow_type": "utf8", + "name": "caption", + "nullable": false + } + ], + "output": { + "arrow_type": "utf8", + "kind": "scalar", + "nullable": false + } + }, + "secret_bindings": [ + { + "kind": "env", + "variable": "OPENAI_API_KEY", + "secret_ref": { + "name": "openai-prod" + } + } + ] +} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json index 2670ad0b2..63f3c824e 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json @@ -1 +1 @@ -{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"} +{"created_at":"2026-08-21T00:00:00Z","disabled":false,"image":{"descriptor":{"behavior":{"result_stability":"input_and_context","side_effects":"non_idempotent"},"entrypoint":"app.function:create","format_version":1,"interface":{"type":"lance.scalar","version":1},"python":{"abi_tag":"cp312","executable":"/usr/local/bin/python3","implementation":"cpython","import_paths":["/opt/function/code"],"version":"3.12.14"},"python_api":1,"requires":{"capabilities":[],"kernel_min":"4.18.0"},"schemas":{"initialization":"/opt/function/schemas/initialization.arrow","input":"/opt/function/schemas/input.arrow","output":"/opt/function/schemas/output.arrow"}},"manifest_digest":"sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6","source":false},"location":"memory:///fixture","metadata":{},"name":"embed","object_id":"fixture","secret_bindings":[{"kind":"env","secret_ref":{"name":"hf-prod"},"variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list","kind":"scalar","nullable":false}},"version":"1"} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_multi_output_declaration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_multi_output_declaration_request.json index c4ef5009f..02b2d5880 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_multi_output_declaration_request.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_multi_output_declaration_request.json @@ -5,7 +5,7 @@ ], "function": { "application": { - "function": {"name": "text_features", "version": "fv_01K3TEXT"}, + "function": {"name": "text_features", "version": "1", "object_id": "fixture", "location": "memory:///fixture", "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs": [ {"parameter": "title", "kind": "column", "value": {"path": "title"}}, {"parameter": "body", "kind": "column", "value": {"path": "body"}} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json index 357834de0..30fe06b91 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_scalar_declaration_request.json @@ -4,7 +4,7 @@ ], "function": { "application": { - "function": {"name": "embed", "version": "fv_01K3EXACT"}, + "function": {"name": "embed", "version": "1", "object_id": "fixture", "location": "memory:///fixture", "manifest_digest": "sha256:7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"}, "inputs": [ {"parameter": "text", "kind": "column", "value": {"path": "description"}} ],