diff --git a/.github/workflows/build-js-bindings.yml b/.github/workflows/build-js-bindings.yml new file mode 100644 index 0000000..ec10224 --- /dev/null +++ b/.github/workflows/build-js-bindings.yml @@ -0,0 +1,183 @@ +# Build, pack and upload seekdb JS bindings for multiple platforms to S3 +# +# Platforms: linux-x64, linux-arm64, darwin-arm64 (macOS x64 not supported) +# S3: s3://oceanbase-seekdb-builds/js-bindings/all_commits//seekdb-js-bindings-.zip +# +name: Build JS bindings +run-name: Build JS bindings for ${{ github.sha }} + +on: + push: + branches: + - main + - master + - develop + - "*.*.x" + - "integration/*" + paths-ignore: + - "!.github/workflows/build-js-bindings*" + - ".github/**" + - "*.md" + - "LICENSE" + - "CODEOWNERS" + - "docs/**" + - "packages/seekdb/**" + - "packages/embeddings/**" + - "examples/**" + workflow_dispatch: + pull_request: + paths-ignore: + - ".github/**" + - "!.github/workflows/build-js-bindings*" + - "*.md" + - "LICENSE" + - "CODEOWNERS" + - "docs/**" + - "packages/seekdb/**" + - "packages/embeddings/**" + - "examples/**" + +env: + AWS_REGION: ${{ vars.AWS_REGION || 'ap-southeast-1' }} + BUCKET_NAME: ${{ vars.AWS_S3_BUCKET || 'oceanbase-seekdb-builds' }} + DESTINATION_TARGET_PATH: ${{ vars.SEEKDB_JS_BINDINGS_S3_PATH || format('s3://oceanbase-seekdb-builds/js-bindings/all_commits/{0}', github.sha) }} + S3_BUCKET: ${{ vars.AWS_S3_BUCKET || 'oceanbase-seekdb-builds' }} + S3_PREFIX: js-bindings/all_commits/${{ github.sha }} + +jobs: + # ---------- Build JS bindings on Linux ---------- + build-linux: + name: Build JS bindings (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x64 + runner: ubuntu-22.04 + - platform: linux-arm64 + runner: ubuntu-22.04-arm + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Setup Python (Linux) + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build bindings (Linux) + working-directory: packages/bindings + run: pnpm run build + + - name: Pack artifact (Linux) + working-directory: packages/bindings/pkgs/js-bindings + run: zip -r ../../../../seekdb-js-bindings-${{ matrix.platform }}.zip . -x "seekdb.js" -x "seekdb.d.ts" -x "package.json" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: seekdb-js-bindings-${{ matrix.platform }} + path: seekdb-js-bindings-${{ matrix.platform }}.zip + + # ---------- Build JS bindings on macOS ---------- + build-macos: + name: Build JS bindings (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: darwin-arm64 + runner: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build bindings (macOS) + working-directory: packages/bindings + run: pnpm run build + + - name: Pack artifact (macOS) + working-directory: packages/bindings/pkgs/js-bindings + run: zip -r ../../../../seekdb-js-bindings-${{ matrix.platform }}.zip . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: seekdb-js-bindings-${{ matrix.platform }} + path: seekdb-js-bindings-${{ matrix.platform }}.zip + + # ---------- Collect artifacts and upload to S3 ---------- + release-artifacts: + name: Collect artifacts and upload to S3 + runs-on: ubuntu-22.04 + needs: + - build-linux + - build-macos + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: release-artifacts + merge-multiple: true + + - name: List all artifacts + run: | + echo "=== All artifacts ===" + ls -la release-artifacts/ + + - name: Configure AWS credentials + if: env.DESTINATION_TARGET_PATH != '' || env.S3_BUCKET != '' + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Upload to S3 + if: env.DESTINATION_TARGET_PATH != '' || env.S3_BUCKET != '' + run: | + set -e + if [ -n "${{ env.DESTINATION_TARGET_PATH }}" ]; then + S3_TARGET="${{ env.DESTINATION_TARGET_PATH }}" + else + S3_TARGET="s3://${{ env.S3_BUCKET }}/${{ env.S3_PREFIX }}/" + fi + [ "${S3_TARGET: -1}" != "/" ] && S3_TARGET="${S3_TARGET}/" + echo "Uploading to $S3_TARGET" + aws s3 cp release-artifacts/ "$S3_TARGET" --recursive --exclude "*" --include "*.zip" --no-progress + echo "Uploaded:" + aws s3 ls "$S3_TARGET" --recursive + echo "Done." + continue-on-error: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad176e8..1a52ef3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,9 +65,10 @@ jobs: - name: Build all packages run: pnpm run build - test: + # Server-mode tests (exclude embedded/; each job runs on its own runner so seekdb-docker cannot be shared across jobs) + test-server: if: github.repository == 'oceanbase/seekdb-js' - needs: [lint, build] + name: Test server runs-on: ubuntu-latest steps: - name: Checkout @@ -95,7 +96,8 @@ jobs: sleep 15 docker logs seekdb-server - - name: Run tests + - name: Run server tests + working-directory: packages/seekdb env: SERVER_HOST: 127.0.0.1 SERVER_PORT: 2881 @@ -103,4 +105,76 @@ jobs: SERVER_PASSWORD: "" SERVER_DATABASE: test SERVER_TENANT: sys - run: pnpm run test + run: pnpm exec vitest run --exclude 'tests/embedded/**' + + # Embedded-mode tests on multiple platforms (requires native bindings build per OS; Docker per job) + test-embedded: + if: github.repository == 'oceanbase/seekdb-js' + name: Test embedded (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x64 + runner: ubuntu-22.04 + - platform: linux-arm64 + runner: ubuntu-22.04-arm + - platform: darwin-arm64 + runner: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build bindings + working-directory: packages/bindings + run: pnpm run build + + - name: Build packages + run: pnpm run build + + # macOS has no Docker; skip container and exclude mode-consistency (needs seekdb server) + - name: Start seekdb container + if: runner.os == 'Linux' + shell: bash + run: | + docker run --name seekdb-server -p 2881:2881 -d oceanbase/seekdb:latest + sleep 15 + docker logs seekdb-server + + - name: Run embedded tests + working-directory: packages/seekdb + env: + SERVER_HOST: 127.0.0.1 + SERVER_PORT: 2881 + SERVER_USER: root + SERVER_PASSWORD: "" + SERVER_DATABASE: test + SERVER_TENANT: sys + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + pnpm exec vitest run tests/embedded/ 2>&1 | tee /tmp/vitest.log + else + pnpm exec vitest run tests/embedded/ --exclude '**/mode-consistency.test.ts' 2>&1 | tee /tmp/vitest.log + fi + exit_code=$? + if [ $exit_code -ne 0 ]; then exit $exit_code; fi + grep -qE "[0-9]+ failed" /tmp/vitest.log && exit 1 + exit 0 diff --git a/.gitignore b/.gitignore index 4d2dcc0..3c5803f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,6 @@ coverage/ .npmrc .pnpm-store .cursor +seekdb.db spec/ -.vscode \ No newline at end of file +.vscode diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..041c660 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx --no-install lint-staged diff --git a/DEVELOP.md b/DEVELOP.md index 12c94fa..f13e3c5 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -2,6 +2,7 @@ - [Development Guide](#development-guide) - [Prerequisites](#prerequisites) + - [Running Modes](#running-modes) - [Run Examples](#run-examples) - [Setup](#setup) - [Run Examples](#run-examples-1) @@ -14,13 +15,16 @@ - **Node.js**: Version >= 20 - **Package Manager**: pnpm -- **Database**: A running seekdb or OceanBase instance is required. - - Default connection config: - - Host: `127.0.0.1` - - Port: `2881` - - User: `root` - - Database: `test` - - Tenant: `sys` (Required for OceanBase mode) +- **Database / running mode**: + - **Embedded mode**: No seekdb server required; install and build, then run examples and tests (using local `seekdb.db` or a custom `path`). Depends on the native addon (see `packages/bindings`). All embedded tests live under `packages/seekdb/tests/embedded/` and mirror server-mode scenarios. + - **Server mode**: A running seekdb or OceanBase instance (local or remote) is required. + - Default connection: Host `127.0.0.1`, Port `2881`, User `root`, Database `test` + - OceanBase mode requires Tenant: `sys` + +## Running Modes + +- **Embedded mode**: `new SeekdbClient({ path: "..." })`. Data is stored under the given path; no server needed. Admin operations use `AdminClient({ path: "..." })`, which returns a `SeekdbClient`. Examples and embedded-only tests run without a database server. +- **Server mode**: `new SeekdbClient({ host, port, ... })` connects to a deployed seekdb/OceanBase. Start the database and verify connection settings before running server-mode examples. ## Run Examples @@ -38,29 +42,30 @@ pnpm build ### Run Examples -This project provides several example in the `packages/examples` directory. You can run them directly from the root directory using the following commands: +Examples live in the root `examples/` directory. From the project root: -- **Simple Example**: - Demonstrates basic connection, collection creation, data addition, and querying. +- **Simple Example**: Basic connection, collection creation, add, and query. ```bash pnpm --filter seekdb-examples run run:simple ``` -- **Complete Example**: - Demonstrates all SDK features, including DML (CRUD), DQL (Query), Hybrid Search, etc. +- **Complete Example**: Full feature demo (DML, DQL, Hybrid Search, etc.). ```bash pnpm --filter seekdb-examples run run:complete ``` -- **Hybrid Search Example**: - Focuses on demonstrating hybrid search functionality. +- **Hybrid Search Example**: Hybrid search usage. + ```bash pnpm --filter seekdb-examples run run:hybrid ``` -> **Note**: The example code connects to a local database (`127.0.0.1:2881`) by default. If your database configuration is different, please modify the `SeekdbClient` configuration in the corresponding `.ts` file under `packages/examples/`. +**Running mode**: + +- Examples use **embedded mode** by default (`path: "./seekdb.db"`); no seekdb server is required. +- For **server mode**, start seekdb/OceanBase and adjust the `SeekdbClient` config in the example (e.g. `host`, `port`, `user`, `password`); see comments in each example file. --- @@ -82,16 +87,26 @@ pnpm build ### Run Tests -The project uses Vitest for testing. Run tests for the core package `seekdb`: +The project uses Vitest. Run tests for the core `seekdb` package from the project root: ```bash # Run all tests pnpm test -# Or run with specific filter +# Run only seekdb package tests pnpm --filter seekdb run test + +# Run only embedded-mode tests (no server required) +pnpm --filter seekdb exec vitest run tests/embedded/ ``` +**Tests and running mode**: + +- **Embedded-mode tests** live under `packages/seekdb/tests/embedded/` and use a temporary database path per test file. They do not require a seekdb/OceanBase server. Run them with the command above when no server is available. +- **Server-mode tests** (under `packages/seekdb/tests/` but outside `embedded/`) connect to `127.0.0.1:2881` and require a local seekdb or OceanBase instance. +- **Mode consistency** tests (`tests/embedded/mode-consistency.test.ts`) run both embedded and server modes in the same file; they require the native addon and a server for the server part. +- Embedded test coverage vs server is documented in `packages/seekdb/tests/embedded/COVERAGE_REPORT.md`. + ### Linting & Formatting ```bash diff --git a/README.md b/README.md index e740449..fc8b4b3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ For complete usage, please refer to the official documentation. [Why seekdb-js?](#why-seekdb-js)
[Packages](#packages)
[Installation](#installation)
+[Running Modes](#running-modes)
[Quick Start](#quick-start)
[Usage Guide](#usage-guide)
[Examples](#examples)
@@ -42,14 +43,29 @@ This is a monorepo containing: ## Installation -> Before using the SDK, you need to deploy seekdb. Please refer to the [official deployment documentation](https://www.oceanbase.ai/docs/deploy-overview/). - ```bash npm install seekdb @seekdb/default-embed ``` +- **Embedded mode**: No seekdb server deployment required; use locally after install. +- **Server mode**: Deploy seekdb or OceanBase first; see [official deployment docs](https://www.oceanbase.ai/docs/deploy-overview/). + +## Running Modes + +The SDK supports two modes; the constructor arguments to `SeekdbClient` determine which is used. For database management (create/list/get/delete database), use `AdminClient()` which returns a `SeekdbClient` instance. + +| Mode | Parameter | Description | +| ------------ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Embedded** | `path` (database directory path) | Runs locally with no separate seekdb server; data is stored under the given path (e.g. `./seekdb.db`). Requires native addon `@seekdb/js-bindings`. | +| **Server** | `host` (and `port`, `user`, `password`, etc.) | Connects to a remote seekdb or OceanBase instance. | + +- **SeekdbClient**: Pass `path` for embedded mode, or `host` (and port, user, password, etc.) for server mode. +- **AdminClient()**: For admin operations only; pass `path` for embedded or `host` for server. In embedded mode you do not specify a database name. + ## Quick Start +**Server mode** (connect to a deployed seekdb): + ```typescript import { SeekdbClient } from "seekdb"; @@ -72,11 +88,32 @@ await collection.add({ }); // 4. Search -const results = await collection.query({ - queryTexts: "Hello", - nResults: 5, +const results = await collection.query({ queryTexts: "Hello", nResults: 5 }); +console.log("query results", results); +``` + +**Embedded mode** (local file, no server): + +```typescript +import { SeekdbClient } from "seekdb"; + +// 1. Connect +const client = new SeekdbClient({ + path: "./seekdb.db", + database: "test", }); +// 2. Create collection +const collection = await client.createCollection({ name: "my_collection" }); + +// 3. Add data (auto-vectorized using @seekdb/default-embed) +await collection.add({ + ids: ["1", "2"], + documents: ["Hello world", "seekdb is fast"], +}); + +// 4. Search +const results = await collection.query({ queryTexts: "Hello", nResults: 5 }); console.log("query results", results); ``` @@ -86,6 +123,8 @@ console.log("query results", results); ### Client Connection +**Server mode** (seekdb / OceanBase): + ```typescript import { SeekdbClient } from "seekdb"; @@ -100,6 +139,17 @@ const client = new SeekdbClient({ }); ``` +**Embedded mode** (local database file): + +```typescript +import { SeekdbClient } from "seekdb"; + +const client = new SeekdbClient({ + path: "./seekdb.db", // database file path + database: "test", +}); +``` + ### Create Collection If you don't specify an embedding function, the default embedding function will be used for vectorization. Please install `@seekdb/default-embed`. @@ -391,31 +441,39 @@ const collection = await client.createCollection({ ### Database Management -The `SeekdbAdminClient` allows you to manage databases (create, list, delete). +Use `AdminClient()` for database management. It returns a `SeekdbClient` instance. In **embedded mode** you only pass `path`; no database name is required. + +**Server mode**: ```typescript -import { SeekdbAdminClient } from "seekdb"; +import { AdminClient } from "seekdb"; -const adminClient = new SeekdbAdminClient({ +const admin = AdminClient({ host: "127.0.0.1", port: 2881, user: "root", password: "", - // Required for OceanBase mode - // tenant: "sys" + // OceanBase mode requires tenant: "sys" }); -// Create a new database -await adminClient.createDatabase("new_database"); - -// List all databases -const databases = await adminClient.listDatabases(); +await admin.createDatabase("new_database"); +const databases = await admin.listDatabases(); +const db = await admin.getDatabase("new_database"); +await admin.deleteDatabase("new_database"); +await admin.close(); +``` -// Get database info -const db = await adminClient.getDatabase("new_database"); +**Embedded mode** (no server): -// Delete a database -await adminClient.deleteDatabase("new_database"); +```typescript +import { AdminClient } from "seekdb"; + +const admin = AdminClient({ path: "./seekdb.db" }); +await admin.createDatabase("new_database"); +const databases = await admin.listDatabases(); +const db = await admin.getDatabase("new_database"); +await admin.deleteDatabase("new_database"); +await admin.close(); ``` ## Examples diff --git a/examples/complete-example.ts b/examples/complete-example.ts index f898884..d792105 100644 --- a/examples/complete-example.ts +++ b/examples/complete-example.ts @@ -20,17 +20,23 @@ async function main() { // PART 1: CLIENT CONNECTION // ============================================================================ - // Connecting to seekdb server or OceanBase) + // Option 1: Embedded mode (local seekdb) const client = new SeekdbClient({ - host: "127.0.0.1", - port: 2881, + path: "./seekdb.db", database: "test", - user: "root", - password: "", - // for OceanBase, set tenant to "sys" - // tenant: "sys", }); + // Option 2: Connecting to seekdb server or OceanBase + // const client = new SeekdbClient({ + // host: "127.0.0.1", + // port: 2881, + // database: "test", + // user: "root", + // password: "", + // // for OceanBase, set tenant to "sys" + // // tenant: "sys", + // }); + // ============================================================================ // PART 2: COLLECTION MANAGEMENT // ============================================================================ diff --git a/examples/hybrid-search-example.ts b/examples/hybrid-search-example.ts index 5ff7f07..4856576 100644 --- a/examples/hybrid-search-example.ts +++ b/examples/hybrid-search-example.ts @@ -13,17 +13,23 @@ import { SeekdbClient } from "seekdb"; const COLLECTION_NAME = "hybrid_search_demo"; async function main() { - // Connecting to seekdb server or OceanBase) + // Option 1: Embedded mode (local seekdb) const client = new SeekdbClient({ - host: "127.0.0.1", - port: 2881, + path: "./seekdb.db", database: "test", - user: "root", - password: "", - // for OceanBase, set tenant to "sys" - // tenant: "sys", }); + // Option 2: Connecting to seekdb server or OceanBase + // const client = new SeekdbClient({ + // host: "127.0.0.1", + // port: 2881, + // database: "test", + // user: "root", + // password: "", + // // for OceanBase, set tenant to "sys" + // // tenant: "sys", + // }); + const collection = await client.getOrCreateCollection({ name: COLLECTION_NAME, }); diff --git a/examples/simple-example.ts b/examples/simple-example.ts index cf4745e..7f32ec2 100644 --- a/examples/simple-example.ts +++ b/examples/simple-example.ts @@ -15,17 +15,26 @@ import { SeekdbClient } from "seekdb"; async function main() { // ==================== Step 1: Create Client Connection ==================== - // Connecting to seekdb server or OceanBase) + // You can use embedded mode, server mode, or OceanBase mode + // For this example, we'll use embedded mode (you can change to server or OceanBase) + + // Option 1: Embedded mode (local seekdb) const client = new SeekdbClient({ - host: "127.0.0.1", - port: 2881, + path: "./seekdb.db", database: "test", - user: "root", - password: "", - // for OceanBase, set tenant to "sys" - // tenant: "sys", }); + // Option 2: Connecting to seekdb server or OceanBase + // const client = new SeekdbClient({ + // host: "127.0.0.1", + // port: 2881, + // database: "test", + // user: "root", + // password: "", + // // for OceanBase, set tenant to "sys" + // // tenant: "sys", + // }); + // ==================== Step 2: Create a Collection with Embedding Function ==================== // A collection is like a table that stores documents with vector embeddings const COLLECTION_NAME = "my_simple_collection"; diff --git a/package.json b/package.json index 15aa42d..4dfcc06 100644 --- a/package.json +++ b/package.json @@ -10,17 +10,24 @@ "lint": "pnpm -r run lint", "type-check": "pnpm -r run type-check", "prettier": "prettier --write .", + "prepare": "husky", "publish:all": "node scripts/publish.js" }, "devDependencies": { "@changesets/cli": "^2.29.8", "@types/node": "^22.19.7", "dotenv": "^16.6.1", + "husky": "^9.1.7", + "lint-staged": "^15.4.3", "prettier": "^3.8.1", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^2.1.9" }, + "lint-staged": { + "*.{ts,tsx,js,jsx,json,md,yml,yaml}": "prettier --write", + "*.{ts,tsx,js,jsx}": "pnpm run lint" + }, "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" diff --git a/packages/bindings/.gitignore b/packages/bindings/.gitignore new file mode 100644 index 0000000..7f0aa7e --- /dev/null +++ b/packages/bindings/.gitignore @@ -0,0 +1,11 @@ +build +libseekdb +pkgs/**/*.node +pkgs/**/*.so +pkgs/**/*.so.* +pkgs/**/*.dylib +pkgs/**/*.dll +pkgs/**/libs +test/tsconfig.tsbuildinfo +*Sigs.json +__pycache__ \ No newline at end of file diff --git a/packages/bindings/README.md b/packages/bindings/README.md new file mode 100644 index 0000000..cc4721b --- /dev/null +++ b/packages/bindings/README.md @@ -0,0 +1,92 @@ +# seekdb Native Bindings + +This directory contains the native addon bindings for seekdb embedded mode, following the architecture pattern from `duckdb-node-neo`. + +## Architecture + +The native addon is structured in three layers: + +1. **C++ Native Addon** (`src/seekdb_js_bindings.cpp`) + - Uses N-API (Node Addon API) to interface with Node.js + - Wraps seekdb C API functions + - Provides low-level bindings for database operations + +2. **JavaScript Wrapper** (`pkgs/js-bindings/seekdb.js`) + - Loads native `.node` from same dir (npm package / local build) or on-demand download (Node fetch + adm-zip) + - Supports Linux (x64/arm64) and macOS (arm64 only). **Native bindings are not on npm**; built by CI and hosted on S3. + +3. **TypeScript API Layer** (`../seekdb/src/client-embedded.ts`) + - High-level TypeScript API + - Uses the native bindings through `@seekdb/js-bindings` + - Provides the same interface as remote server mode + +## Distribution (S3, not npm) + +Native bindings are **not** published to npm. They are built by [`.github/workflows/build-js-bindings.yml`](../../.github/workflows/build-js-bindings.yml) and uploaded to S3. Each set of artifacts lives in a directory that contains `seekdb-js-bindings-.zip` for each platform (e.g. linux-x64, linux-arm64, darwin-arm64). + +**Usage**: When embedded mode is first used, the loader uses same-dir `seekdb.node` (npm package or local build) or downloads bindings on demand. Optional env: + +- `SEEKDB_BINDINGS_BASE_URL` – URL of the directory that contains the zip files (parent of `seekdb-js-bindings-.zip`). Defaults to a built-in URL. +- `SEEKDB_BINDINGS_CACHE_DIR` – cache directory for the downloaded zip to avoid repeated downloads (default: `~/.seekdb/bindings`). The zip is stored here and extracted for loading; subsequent runs reuse the cached zip. + +The loader **`pkgs/js-bindings`** resolves the native addon from the same directory (`seekdb.node`) or via on-demand download. + +## Building (CI / local dev) + +To build the native addon locally (e.g. for development): + +```bash +cd bindings +pnpm install +pnpm run build +``` + +This will: + +1. Fetch the libseekdb library for your platform (Python scripts invoked by `binding.gyp`) +2. If the archive contains a `libs/` directory, copy it to `pkgs/js-bindings/libs/` (e.g. macOS runtime deps) +3. Compile the C++ bindings with node-gyp and copy `seekdb.node` and `libseekdb.so`/`libseekdb.dylib` into `pkgs/js-bindings/` + +## Platform Support + +The bindings support the following platforms: + +- Linux x64 +- Linux arm64 +- macOS arm64 (Apple Silicon) + +Note: macOS x64 and Windows are not currently supported. + +## C API Integration + +The bindings use the seekdb C API (see `seekdb.h` in `libseekdb/` after fetch) and link against `libseekdb.so` / `libseekdb.dylib`. The native library is downloaded and extracted by platform-specific Python scripts in `scripts/` (invoked from `binding.gyp`); see `scripts/README.md` for details. + +### Current Implementation + +- ✅ Database open/close operations +- ✅ Connection management +- ✅ Async SQL execution with Promise-based API +- ✅ Result set handling with row/column access +- ✅ Error handling + +### Naming Convention + +All C++ wrapper types use `Seekdb` (db in lowercase) to match the seekdb package naming convention: + +- `SeekdbDatabase` - Database wrapper +- `SeekdbConnection` - Connection wrapper +- `SeekdbResultWrapper` - Result wrapper (named `Wrapper` to avoid conflict with C API `SeekdbResult` type) +- `SeekdbNodeAddon` - Main addon class + +Note: C API types (`SeekdbHandle`, `SeekdbResult`, `SeekdbRow`) from seekdb.h use lowercase "db" to match the seekdb package naming convention. + +### Package Structure + +- **`@seekdb/js-bindings`** – Loader: same-dir `seekdb.node` or on-demand download (cached under `SEEKDB_BINDINGS_CACHE_DIR`). Binaries are built by CI and hosted on S3 (not npm). + +### TODO + +- [ ] Support for transactions (begin/commit/rollback) +- [ ] Support for execute_update (INSERT/UPDATE/DELETE) +- [ ] Add comprehensive tests for native bindings +- [ ] Support for additional data types (beyond string) diff --git a/packages/bindings/binding.gyp b/packages/bindings/binding.gyp new file mode 100644 index 0000000..31e2d36 --- /dev/null +++ b/packages/bindings/binding.gyp @@ -0,0 +1,128 @@ +{ + 'targets': [ + { + 'target_name': 'fetch_libseekdb', + 'type': 'none', + 'conditions': [ + ['OS=="linux" and target_arch=="x64"', { + 'actions': [{ + 'action_name': 'run_fetch_libseekdb_script', + 'message': 'Fetching and extracting libseekdb', + 'inputs': [], + 'action': ['sh', '-c', 'cd "<(module_root_dir)" && python3 scripts/fetch_libseekdb_linux_x64.py'], + 'outputs': ['<(module_root_dir)/libseekdb/libseekdb.so'], + }], + }], + ['OS=="linux" and target_arch=="arm64"', { + 'actions': [{ + 'action_name': 'run_fetch_libseekdb_script', + 'message': 'Fetching and extracting libseekdb', + 'inputs': [], + 'action': ['sh', '-c', 'cd "<(module_root_dir)" && python3 scripts/fetch_libseekdb_linux_arm64.py'], + 'outputs': ['<(module_root_dir)/libseekdb/libseekdb.so'], + }], + }], + ['OS=="mac" and target_arch=="arm64"', { + 'actions': [{ + 'action_name': 'run_fetch_libseekdb_script', + 'message': 'Fetching and extracting libseekdb', + 'inputs': [], + 'action': ['sh', '-c', 'cd "<(module_root_dir)" && python3 scripts/fetch_libseekdb_darwin_arm64.py'], + 'outputs': ['<(module_root_dir)/libseekdb/libseekdb.dylib'], + }], + }], + ], + }, + { + 'target_name': 'seekdb', + 'dependencies': [ + 'fetch_libseekdb', + '=18" + } +} diff --git a/packages/bindings/pkgs/js-bindings/seekdb.d.ts b/packages/bindings/pkgs/js-bindings/seekdb.d.ts new file mode 100644 index 0000000..155733d --- /dev/null +++ b/packages/bindings/pkgs/js-bindings/seekdb.d.ts @@ -0,0 +1,89 @@ +/** + * Type definitions for seekdb native bindings + * + * These types correspond to the C API types and C++ wrapper classes: + * - Database -> SeekdbDatabase (wrapper) + * - Connection -> SeekdbConnection (wrapper, uses SeekdbHandle from C API) + * - Result -> SeekdbResultWrapper (wrapper, uses SeekdbResult from C API) + * + * C API types (from seekdb.h): + * - SeekdbHandle - Connection handle + * - SeekdbResult - Query result handle + * - SeekdbRow - Row handle + */ + +/** + * Database handle - opaque type representing a seekdb database instance + * Corresponds to SeekdbDatabase in C++ bindings + */ +export interface Database { + // Opaque type - internal handle +} + +/** + * Connection handle - opaque type representing a database connection + * Corresponds to SeekdbConnection in C++ bindings + */ +export interface Connection { + // Opaque type - internal handle +} + +/** + * Query result - contains rows and column information + * Corresponds to SeekdbResultWrapper in C++ bindings + */ +export interface Result { + /** Array of rows, where each row is an array of values */ + rows: any[][]; + /** Array of column names */ + columns: string[]; +} + +/** + * Open a seekdb database + * @param db_dir - Database directory path (optional, defaults to current directory) + * @returns Database handle + * @throws Error if database cannot be opened + */ +export function open(db_dir?: string): Database; + +/** + * Close a seekdb database synchronously + * @param database - Database handle returned from open() + */ +export function close_sync(database: Database): void; + +/** + * Create a connection to a database + * @param database - Database handle returned from open() + * @param database_name - Name of the database to connect to + * @param autocommit - Whether to enable autocommit mode + * @returns Connection handle + * @throws Error if connection cannot be established + */ +export function connect( + database: Database, + database_name: string, + autocommit: boolean +): Connection; + +/** + * Disconnect from a database + * @param connection - Connection handle returned from connect() + */ +export function disconnect(connection: Connection): void; + +/** + * Execute a SQL query asynchronously + * @param connection - Connection handle returned from connect() + * @param sql - SQL query string (may contain ? placeholders for parameters) + * @param params - Optional array of parameters to replace ? placeholders + * @returns Promise that resolves with query results + * @throws Error if query execution fails + * @note Column name inference is handled automatically by C ABI layer + */ +export function execute( + connection: Connection, + sql: string, + params?: any[] +): Promise; diff --git a/packages/bindings/pkgs/js-bindings/seekdb.js b/packages/bindings/pkgs/js-bindings/seekdb.js new file mode 100644 index 0000000..bfb3b4c --- /dev/null +++ b/packages/bindings/pkgs/js-bindings/seekdb.js @@ -0,0 +1,54 @@ +const path = require("path"); +const { ensureBindingsDownloaded } = require("./download.js"); + +/** Sync load from same dir (npm package / local build). Returns null if not found. */ +function getNativeNodeBindingSync() { + try { + return require(path.join(__dirname, "seekdb.node")); + } catch { + return null; + } +} + +let _cachedBinding = null; +let _loadPromise = null; + +/** Async load: try sync, else on-demand download then load from cache. Dedupes concurrent calls. */ +async function getNativeBindingAsync() { + if (_cachedBinding) return _cachedBinding; + try { + const sync = getNativeNodeBindingSync(); + if (sync) { + _cachedBinding = sync; + return sync; + } + } catch (_) { + // Sync load failed; fall back to download + } + if (_loadPromise) return _loadPromise; + _loadPromise = (async () => { + try { + const cacheDir = await ensureBindingsDownloaded(); + _cachedBinding = require(path.join(cacheDir, "seekdb.node")); + return _cachedBinding; + } catch (err) { + _loadPromise = null; + throw err; + } + })(); + return _loadPromise; +} + +const syncBinding = getNativeNodeBindingSync(); + +if (syncBinding) { + // Bindings available: export them directly; async helper returns same instance + _cachedBinding = syncBinding; + module.exports = syncBinding; + module.exports.getNativeBindingAsync = () => Promise.resolve(syncBinding); +} else { + // Bindings not available: export only async API (on-demand download when called) + module.exports = { + getNativeBindingAsync, + }; +} diff --git a/packages/bindings/scripts/README.md b/packages/bindings/scripts/README.md new file mode 100644 index 0000000..d5a494f --- /dev/null +++ b/packages/bindings/scripts/README.md @@ -0,0 +1,86 @@ +# SeekDB Bindings Scripts + +This directory contains Python scripts for managing the seekdb native bindings, following the pattern from duckdb-node-neo. + +## Scripts + +### `fetch_libseekdb.py` + +Generic utility module for downloading libseekdb library files from a URL (zip archive). + +**Function signature:** + +```python +fetch_libseekdb(zip_url, output_dir) +``` + +Downloads the zip and extracts all contents into `output_dir`. + +### `libseekdb_url_config.py` + +**URL is maintained here.** All platform scripts get the zip download prefix from this file: + +- Current: S3 build artifacts (`oceanbase-seekdb-builds.s3.ap-southeast-1.amazonaws.com`) +- Original GitHub releases URL is kept in the file but commented out; uncomment to switch back + +To change the download source or commit path, edit `LIBSEEKDB_URL_PREFIX` in `libseekdb_url_config.py`. + +### Platform-specific fetch scripts + +These scripts download libseekdb files for specific platforms. They are automatically called by `node-gyp` during the build process via `binding.gyp`: + +- `fetch_libseekdb_linux_x64.py` - Linux x64 +- `fetch_libseekdb_linux_arm64.py` - Linux arm64 +- `fetch_libseekdb_darwin_arm64.py` - macOS arm64 (Apple Silicon) + +Note: Windows and macOS x64 (Intel Silicon) is not currently supported. + +**Manual usage (if needed):** + +```bash +python scripts/fetch_libseekdb_linux_x64.py +``` + +Each script specifies: + +- `zip_url`: Built from the shared prefix via `libseekdb_url_config.get_zip_url()` +- `output_dir`: Directory to extract all zip contents to (defaults to `../libseekdb`) + +### `checkFunctionSignatures.mjs` + +Checks that function signatures in TypeScript definitions and C++ bindings match the C API header. + +**Usage:** + +```bash +# Check signatures +node scripts/checkFunctionSignatures.mjs + +# Write signature files for comparison +node scripts/checkFunctionSignatures.mjs writeFiles + +# Remove signature files +node scripts/checkFunctionSignatures.mjs removeFiles +``` + +## NPM Scripts + +The following npm scripts are available in `package.json`: + +```bash +# Build (automatically fetches libseekdb via node-gyp) +pnpm run build + +# Check function signatures +pnpm run check:signatures +``` + +Note: The libseekdb library is automatically fetched during the build process through `binding.gyp` dependencies. No manual fetch scripts are needed. + +## Dependencies + +Python 3.x is required. The scripts use standard library modules: + +- `os` - File system operations +- `urllib.request` - HTTP downloads +- `zipfile` - Zip archive extraction diff --git a/packages/bindings/scripts/checkFunctionSignatures.mjs b/packages/bindings/scripts/checkFunctionSignatures.mjs new file mode 100755 index 0000000..3e4d5ef --- /dev/null +++ b/packages/bindings/scripts/checkFunctionSignatures.mjs @@ -0,0 +1,101 @@ +import fs from "fs"; +import path from "path"; + +function getFunctionSignaturesFromHeader(headerFilePath) { + const sigs = []; + const headerContents = fs.readFileSync(headerFilePath, { encoding: "utf-8" }); + // Match seekdb C API function signatures + // Pattern: return_type seekdb_function_name(...); + const sigRegex = + /^(?\w+(?:\s+\*)?)\s+seekdb_\w+\s*\((?[^)]*)\)\s*;$/gm; + var match; + while ((match = sigRegex.exec(headerContents)) !== null) { + const fullSig = `${match.groups.returnType} seekdb_${match[0].match(/seekdb_(\w+)/)?.[1]}(${match.groups.params});`; + sigs.push({ sig: fullSig.trim().replace(/\s+/g, " ") }); + } + + // Also match typedefs for handles + const typedefRegex = /^typedef\s+(?.*?)\s+(?SeekDB\w+);$/gm; + while ((match = typedefRegex.exec(headerContents)) !== null) { + sigs.push({ sig: `typedef ${match.groups.type} ${match.groups.name};` }); + } + + return sigs; +} + +function getFunctionSignaturesFromComments(filePath) { + const sigs = []; + if (!fs.existsSync(filePath)) { + return sigs; + } + const fileContents = fs.readFileSync(filePath, { encoding: "utf-8" }); + // Match commented function signatures + const sigRegex = /^\s*\/\/\s*SEEKDB_C_API\s+(?([^;])*);$/gm; + var match; + while ((match = sigRegex.exec(fileContents)) !== null) { + sigs.push({ sig: match.groups.sig.trim() }); + } + return sigs; +} + +function checkFunctionSignatures() { + try { + if (process.argv[2] === "removeFiles") { + if (fs.existsSync("headerSigs.json")) { + fs.rmSync("headerSigs.json"); + } + if (fs.existsSync("typeDefsSigs.json")) { + fs.rmSync("typeDefsSigs.json"); + } + if (fs.existsSync("bindingsSigs.json")) { + fs.rmSync("bindingsSigs.json"); + } + return; + } + + const headerFilePath = path.join("libseekdb", "seekdb.h"); + const typeDefsFilePath = path.join("pkgs", "js-bindings", "seekdb.d.ts"); + const bindingsFilePath = path.join("src", "seekdb_js_bindings.cpp"); + + if (!fs.existsSync(headerFilePath)) { + console.warn(`Warning: Header file not found: ${headerFilePath}`); + console.warn("Run fetch script first to download the header file."); + return; + } + + const headerSigs = getFunctionSignaturesFromHeader(headerFilePath); + const typeDefsSigs = getFunctionSignaturesFromComments(typeDefsFilePath); + const bindingsSigs = getFunctionSignaturesFromComments(bindingsFilePath); + + console.log(`Header sigs: ${headerSigs.length}`); + console.log(`Type defs sigs: ${typeDefsSigs.length}`); + console.log(`Bindings sigs: ${bindingsSigs.length}`); + + const headerSigsJSON = JSON.stringify(headerSigs, null, 2); + const typeDefsSigsJSON = JSON.stringify(typeDefsSigs, null, 2); + const bindingsSigsJSON = JSON.stringify(bindingsSigs, null, 2); + + if (headerSigsJSON === typeDefsSigsJSON) { + console.log("OK: Type defs sigs match header sigs"); + } else { + console.warn("WARNING: Type defs sigs DO NOT match header sigs!"); + } + + if (headerSigsJSON === bindingsSigsJSON) { + console.log("OK: Bindings sigs match header sigs"); + } else { + console.warn("WARNING: Bindings sigs DO NOT match header sigs!"); + } + + if (process.argv[2] === "writeFiles") { + fs.writeFileSync("headerSigs.json", headerSigsJSON); + fs.writeFileSync("typeDefsSigs.json", typeDefsSigsJSON); + fs.writeFileSync("bindingsSigs.json", bindingsSigsJSON); + } + } catch (e) { + console.error(e); + process.exit(1); + } +} + +checkFunctionSignatures(); diff --git a/packages/bindings/scripts/fetch_libseekdb.py b/packages/bindings/scripts/fetch_libseekdb.py new file mode 100644 index 0000000..8d2a540 --- /dev/null +++ b/packages/bindings/scripts/fetch_libseekdb.py @@ -0,0 +1,155 @@ +import os +import platform +import shutil +import sys +import urllib.request +import zipfile + + +def _reporthook(block_num, block_size, total_size): + if total_size <= 0: + downloaded = block_num * block_size + print(" downloaded %.1f MB" % (downloaded / (1024 * 1024)), file=sys.stderr) + sys.stderr.flush() + return + downloaded = min(block_num * block_size, total_size) + pct = 100.0 * downloaded / total_size + pct_int = int(pct) + if not hasattr(_reporthook, "_last_pct"): + _reporthook._last_pct = -1 + if pct_int > _reporthook._last_pct or downloaded >= total_size: + _reporthook._last_pct = pct_int + print(" %.0f%% (%.1f / %.1f MB)" % (pct, downloaded / (1024 * 1024), total_size / (1024 * 1024)), file=sys.stderr) + sys.stderr.flush() + + +def _ensure_output_dir_valid(output_dir): + """ + Remove output_dir if it exists but is empty or missing the native lib. + So gyp will re-run fetch and we re-download. + """ + if not os.path.exists(output_dir) or not os.path.isdir(output_dir): + return + entries = os.listdir(output_dir) + if not entries: + shutil.rmtree(output_dir) + return + has_lib = ( + os.path.isfile(os.path.join(output_dir, "libseekdb.dylib")) or + os.path.isfile(os.path.join(output_dir, "libseekdb.so"))) + if not has_lib: + shutil.rmtree(output_dir) + + +def fetch_libseekdb(zip_url, output_dir, local_zip_name): + """ + Download zip from zip_url and extract all contents into output_dir. + local_zip_name: filename for the local zip (e.g. libseekdb-darwin-arm64.zip). + """ + _ensure_output_dir_valid(output_dir) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + local_zip_path = os.path.join(output_dir, local_zip_name) + print("fetching: " + zip_url) + _reporthook._last_pct = -1 + urllib.request.urlretrieve(zip_url, local_zip_path, reporthook=_reporthook) + print(file=sys.stderr) + + print("extracting to " + output_dir) + zf = zipfile.ZipFile(local_zip_path) + names = zf.namelist() + n = len(names) + for i, name in enumerate(names): + zf.extract(name, output_dir) + print("\r %d/%d %s" % (i + 1, n, name), end="", file=sys.stderr) + sys.stderr.flush() + print(file=sys.stderr) + zf.close() + + # If extracted archive has a libs dir, copy it to pkgs/js-bindings/libs. + module_root = os.path.dirname(output_dir) + copy_libs_to_package(module_root) + + +def copy_libs_to_package(module_root): + """ + Copy libseekdb/libs to pkgs/js-bindings/libs if source exists. + Used after fetch (in fetch_libseekdb) and when fetch is skipped (--copy-only). + """ + os.makedirs(os.path.join(module_root, "pkgs", "js-bindings"), exist_ok=True) + src_libs = os.path.join(module_root, "libseekdb", "libs") + dst_libs = os.path.join(module_root, "pkgs", "js-bindings", "libs") + if os.path.isdir(src_libs): + shutil.copytree(src_libs, dst_libs, dirs_exist_ok=True) + + +def _sign_dylibs_macos(bindings_dir): + """ + Ad-hoc sign dylibs in pkgs/js-bindings so macOS does not kill the process (SIGKILL) + when loading libseekdb.dylib with invalid/modified signature. + """ + if sys.platform != "darwin": + return + import subprocess + main_dylib = os.path.join(bindings_dir, "libseekdb.dylib") + if os.path.isfile(main_dylib): + subprocess.run(["codesign", "--force", "--sign", "-", main_dylib], check=False) + libs_dir = os.path.join(bindings_dir, "libs") + if os.path.isdir(libs_dir): + for name in os.listdir(libs_dir): + if name.endswith(".dylib"): + subprocess.run( + ["codesign", "--force", "--sign", "-", os.path.join(libs_dir, name)], + check=False, + ) + + +def _need_fetch(output_dir): + """True if output_dir is missing or empty or does not contain the native lib.""" + if not os.path.exists(output_dir) or not os.path.isdir(output_dir): + return True + entries = os.listdir(output_dir) + if not entries: + return True + has_lib = ( + os.path.isfile(os.path.join(output_dir, "libseekdb.dylib")) or + os.path.isfile(os.path.join(output_dir, "libseekdb.so"))) + return not has_lib + + +def fetch_if_empty(module_root): + """ + If libseekdb is empty or missing the lib, run platform-appropriate fetch so node-gyp build can link. + Called before node-gyp build to avoid COPY/cp failures when libseekdb was empty but build/ existed. + """ + output_dir = os.path.join(module_root, "libseekdb") + if not _need_fetch(output_dir): + return + machine = platform.machine().lower() + uname = getattr(platform, "uname", lambda: None)() + if uname: + system = (uname[0] or "").lower() + else: + system = "darwin" if sys.platform == "darwin" else "linux" + arch = "arm64" if machine in ("arm64", "aarch64") else "x64" + if system == "darwin": + zip_name = "libseekdb-darwin-arm64.zip" if arch == "arm64" else "libseekdb-darwin-x64.zip" + else: + zip_name = "libseekdb-linux-%s.zip" % arch + from libseekdb_url_config import get_zip_url + zip_url = get_zip_url(zip_name) + fetch_libseekdb(zip_url, output_dir, "libseekdb.zip") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit(0) + module_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if sys.argv[1] == "--copy-only": + copy_libs_to_package(module_root) + elif sys.argv[1] == "--fetch-if-empty": + fetch_if_empty(module_root) + elif sys.argv[1] == "--sign-dylibs": + bindings_dir = os.path.join(module_root, "pkgs", "js-bindings") + _sign_dylibs_macos(bindings_dir) \ No newline at end of file diff --git a/packages/bindings/scripts/fetch_libseekdb_darwin_arm64.py b/packages/bindings/scripts/fetch_libseekdb_darwin_arm64.py new file mode 100644 index 0000000..8884f53 --- /dev/null +++ b/packages/bindings/scripts/fetch_libseekdb_darwin_arm64.py @@ -0,0 +1,10 @@ +import os + +from fetch_libseekdb import fetch_libseekdb +from libseekdb_url_config import get_zip_url + +ZIP_NAME = "libseekdb-darwin-arm64.zip" +zip_url = get_zip_url(ZIP_NAME) +output_dir = os.path.join(os.path.dirname(__file__), "..", "libseekdb") + +fetch_libseekdb(zip_url, output_dir, "libseekdb.zip") diff --git a/packages/bindings/scripts/fetch_libseekdb_linux_arm64.py b/packages/bindings/scripts/fetch_libseekdb_linux_arm64.py new file mode 100644 index 0000000..1a9113f --- /dev/null +++ b/packages/bindings/scripts/fetch_libseekdb_linux_arm64.py @@ -0,0 +1,10 @@ +import os + +from fetch_libseekdb import fetch_libseekdb +from libseekdb_url_config import get_zip_url + +ZIP_NAME = "libseekdb-linux-arm64.zip" +zip_url = get_zip_url(ZIP_NAME) +output_dir = os.path.join(os.path.dirname(__file__), "..", "libseekdb") + +fetch_libseekdb(zip_url, output_dir, "libseekdb.zip") diff --git a/packages/bindings/scripts/fetch_libseekdb_linux_x64.py b/packages/bindings/scripts/fetch_libseekdb_linux_x64.py new file mode 100644 index 0000000..166a114 --- /dev/null +++ b/packages/bindings/scripts/fetch_libseekdb_linux_x64.py @@ -0,0 +1,10 @@ +import os + +from fetch_libseekdb import fetch_libseekdb +from libseekdb_url_config import get_zip_url + +ZIP_NAME = "libseekdb-linux-x64.zip" +zip_url = get_zip_url(ZIP_NAME) +output_dir = os.path.join(os.path.dirname(__file__), "..", "libseekdb") + +fetch_libseekdb(zip_url, output_dir, "libseekdb.zip") diff --git a/packages/bindings/scripts/libseekdb_url_config.py b/packages/bindings/scripts/libseekdb_url_config.py new file mode 100644 index 0000000..8787661 --- /dev/null +++ b/packages/bindings/scripts/libseekdb_url_config.py @@ -0,0 +1,9 @@ +# libseekdb zip download URL config + +LIBSEEKDB_URL_PREFIX = "https://oceanbase-seekdb-builds.s3.ap-southeast-1.amazonaws.com/libseekdb/all_commits/fd681fdabfe8b5a2191b11007b70641e1a091c13/" + +# LIBSEEKDB_URL_PREFIX = "https://github.com/oceanbase/seekdb/releases/download/v1.1.0/" + +def get_zip_url(platform_zip_name): + """Return full download URL for the given zip name (e.g. libseekdb-darwin-arm64.zip).""" + return LIBSEEKDB_URL_PREFIX + platform_zip_name diff --git a/packages/bindings/src/seekdb_js_bindings.cpp b/packages/bindings/src/seekdb_js_bindings.cpp new file mode 100644 index 0000000..7bc8937 --- /dev/null +++ b/packages/bindings/src/seekdb_js_bindings.cpp @@ -0,0 +1,882 @@ +/* + * SeekDB Node.js N-API bindings. + */ +#define NODE_ADDON_API_DISABLE_DEPRECATED +#define NODE_ADDON_API_REQUIRE_BASIC_FINALIZERS +#define NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED +#include "napi.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "seekdb.h" + +#define DEFAULT_SEEKDB_API "js-bindings" + +// Type tags for external objects +static const napi_type_tag DatabaseTypeTag = { 0x1234567890123456ULL, 0x7890123456789012ULL }; +static const napi_type_tag ConnectionTypeTag = { 0x2345678901234567ULL, 0x8901234567890123ULL }; +static const napi_type_tag ResultTypeTag = { 0x4567890123456789ULL, 0x0123456789012345ULL }; + +// Database wrapper - just a marker, actual state is global in seekdb +struct SeekdbDatabase { + std::string db_dir; + + SeekdbDatabase(const std::string& dir) : db_dir(dir) {} + ~SeekdbDatabase() { + // Database is closed globally via seekdb_close() + } +}; + +// Connection wrapper +struct SeekdbConnection { + SeekdbHandle handle; + std::string db_name; + bool autocommit; + + SeekdbConnection(SeekdbHandle h, const std::string& name, bool ac) + : handle(h), db_name(name), autocommit(ac) {} + + ~SeekdbConnection() { + if (handle) { + seekdb_connect_close(handle); + handle = nullptr; + } + } +}; + +// Result wrapper +struct SeekdbResultWrapper { + SeekdbResult result; + int64_t row_count; + int32_t column_count; + std::vector column_names; + std::vector field_info; // Field type information for optimized type detection + int current_row; + char** allocated_names; // For seekdb_result_get_all_column_names_alloc() + + SeekdbResultWrapper(SeekdbResult r) + : result(r), row_count(0), column_count(0), current_row(-1), allocated_names(nullptr) { + if (result) { + // Use new API: seekdb_num_rows and seekdb_num_fields + row_count = static_cast(seekdb_num_rows(result)); + int64_t raw_column_count = static_cast(seekdb_num_fields(result)); + + // Handle column_count: -1 means no result set (DML), treat as 0 + // 0 means DML statement (INSERT/UPDATE/DELETE), which is normal + // > 0 means SELECT statement with columns + // Reference implementation treats -1 as 0 for DML statements + if (raw_column_count < 0) { + column_count = 0; // Treat -1 as 0 for DML statements + } else if (raw_column_count > INT32_MAX) { + column_count = 0; // Invalid column count, treat as 0 + } else { + column_count = static_cast(raw_column_count); + } + + // Get field information for optimized type detection + if (column_count > 0) { + SeekdbField* fields = seekdb_fetch_fields(result); + if (fields) { + for (int32_t i = 0; i < column_count; i++) { + field_info.push_back(&fields[i]); + } + } + } + + if (column_count > 0) { + char** names = nullptr; + int32_t actual_count = column_count; + int ret = seekdb_result_get_all_column_names_alloc(result, &names, &actual_count); + if (ret == SEEKDB_SUCCESS && names && actual_count == column_count) { + allocated_names = names; + for (int32_t i = 0; i < column_count; i++) { + if (names[i]) { + column_names.push_back(std::string(names[i])); + } else { + column_names.push_back("col_" + std::to_string(i)); + } + } + } else { + // Fallback: get column names one by one (similar to reference implementation) + for (int32_t i = 0; i < column_count; i++) { + // Use fixed-size buffer like reference implementation (256 bytes) + std::vector name_buf(256, 0); + int ret = seekdb_result_column_name(result, i, name_buf.data(), name_buf.size()); + if (ret == SEEKDB_SUCCESS) { + size_t actual_len = strlen(name_buf.data()); + if (actual_len > 0) { + column_names.push_back(std::string(name_buf.data(), actual_len)); + continue; + } + } + + // Fallback: try with name_len first (for longer names) + size_t name_len = seekdb_result_column_name_len(result, i); + if (name_len != static_cast(-1) && name_len > 0 && name_len < 1024) { + std::vector len_buf(name_len + 1, 0); + if (seekdb_result_column_name(result, i, len_buf.data(), len_buf.size()) == SEEKDB_SUCCESS) { + column_names.push_back(std::string(len_buf.data())); + continue; + } + } + + // Last resort: use default name (similar to reference implementation) + char default_name[64]; + snprintf(default_name, sizeof(default_name), "col_%d", i); + column_names.push_back(std::string(default_name)); + } + } + } + } + } + + ~SeekdbResultWrapper() { + // Allocated names: caller must free + if (allocated_names && column_count > 0) { + seekdb_free_column_names(allocated_names, column_count); + allocated_names = nullptr; + } + // field_info: pointers into result set; valid until seekdb_result_free(result), do not free + if (result) { + seekdb_result_free(result); + result = nullptr; + } + } +}; + +// Helper functions to get objects from external +template +T* GetFromExternal(Napi::Env env, Napi::Value value, const napi_type_tag& type_tag) { + if (!value.IsExternal()) { + throw Napi::TypeError::New(env, "Expected external object"); + } + auto external = value.As>(); + if (!external.CheckTypeTag(&type_tag)) { + throw Napi::TypeError::New(env, "Invalid type tag"); + } + return external.Data(); +} + +SeekdbDatabase* GetDatabaseFromExternal(Napi::Env env, Napi::Value value) { + return GetFromExternal(env, value, DatabaseTypeTag); +} + +SeekdbConnection* GetConnectionFromExternal(Napi::Env env, Napi::Value value) { + return GetFromExternal(env, value, ConnectionTypeTag); +} + +SeekdbResultWrapper* GetResultFromExternal(Napi::Env env, Napi::Value value) { + return GetFromExternal(env, value, ResultTypeTag); +} + +// Create external objects +template +Napi::External CreateExternal(Napi::Env env, const napi_type_tag& type_tag, T* data) { + auto external = Napi::External::New(env, data, [](Napi::Env, T* data) { + delete data; + }); + external.TypeTag(&type_tag); + return external; +} + +// Returns which parameter indices (0-based) correspond to CAST(? AS BINARY) (_id) placeholders. +// C ABI expects SEEKDB_TYPE_VARBINARY_ID for those so it can right-pad/truncate to 512 bytes. +static std::vector get_varbinary_id_param_indices(const std::string& sql, uint32_t param_count) { + std::vector result(param_count, false); + uint32_t param_index = 0; + const size_t sql_len = sql.size(); + for (size_t pos = 0; pos < sql_len && param_index < param_count; ++pos) { + if (sql[pos] == '?') { + if (pos >= 5 && pos + 12 <= sql_len && + sql.compare(pos - 5, 5, "CAST(") == 0 && + sql.compare(pos + 1, 11, " AS BINARY)") == 0) { + result[param_index] = true; + } + ++param_index; + } + } + return result; +} + +// Async worker for execute operation +class ExecuteWorker : public Napi::AsyncWorker { + public: + ExecuteWorker(Napi::Promise::Deferred deferred, SeekdbConnection* conn, const std::string& sql) + : Napi::AsyncWorker(deferred.Env()), deferred_(deferred), conn_(conn), sql_(sql), + has_params_(false), param_count_(0), result_(nullptr) {} + + ExecuteWorker(Napi::Promise::Deferred deferred, SeekdbConnection* conn, const std::string& sql, + const Napi::Array& params) + : Napi::AsyncWorker(deferred.Env()), deferred_(deferred), conn_(conn), sql_(sql), + has_params_(true), result_(nullptr) { + // Extract parameter values in constructor (main thread) before Execute() runs + // This is safe because constructor runs on main thread + Napi::Env env = deferred.Env(); + Napi::HandleScope scope(env); + + param_count_ = params.Length(); + if (param_count_ > 0) { + param_types_.reserve(param_count_); + param_strings_.reserve(param_count_); + param_numbers_.reserve(param_count_); + param_bools_.reserve(param_count_); + + for (uint32_t i = 0; i < param_count_; i++) { + Napi::Value param = params.Get(i); + + if (param.IsNull() || param.IsUndefined()) { + param_types_.push_back(SEEKDB_TYPE_NULL); + param_strings_.push_back(""); + param_numbers_.push_back(0); + param_bools_.push_back(false); + } else if (param.IsString()) { + param_types_.push_back(SEEKDB_TYPE_STRING); + param_strings_.push_back(param.As().Utf8Value()); + param_numbers_.push_back(0); + param_bools_.push_back(false); + } else if (param.IsNumber()) { + double num_val = param.As().DoubleValue(); + param_numbers_.push_back(num_val); + // Check if it's an integer + if (num_val == static_cast(num_val)) { + param_types_.push_back(SEEKDB_TYPE_LONGLONG); + } else { + param_types_.push_back(SEEKDB_TYPE_DOUBLE); + } + param_strings_.push_back(""); + param_bools_.push_back(false); + } else if (param.IsBoolean()) { + param_types_.push_back(SEEKDB_TYPE_TINY); + param_bools_.push_back(param.As().Value()); + param_strings_.push_back(""); + param_numbers_.push_back(0); + } else { + // Convert to string + param_types_.push_back(SEEKDB_TYPE_STRING); + param_strings_.push_back(param.ToString().Utf8Value()); + param_numbers_.push_back(0); + param_bools_.push_back(false); + } + } + } + } + + ~ExecuteWorker() { + if (result_) { + delete result_; + } + } + + protected: + void Execute() override { + SeekdbResult seekdb_result = nullptr; + int ret; + + if (has_params_ && param_count_ > 0) { + // Which parameters are _id (CAST(? AS BINARY)) - C ABI uses SEEKDB_TYPE_VARBINARY_ID for 512-byte padding + std::vector varbinary_id_flags = get_varbinary_id_param_indices(sql_, param_count_); + + // Build SeekdbBind array from pre-extracted parameter values + // This is safe because we're only using C++ types, no NAPI objects + std::vector binds; + std::vector> string_buffers; // Keep string buffers alive + std::vector lengths; + // Use uint8_t instead of bool for null_flags (std::vector is specialized and can't take address) + std::vector null_flags; + std::vector int_values; + std::vector double_values; + // Use uint8_t instead of bool for bool_values (std::vector is specialized and can't take address) + std::vector bool_values; + + for (uint32_t i = 0; i < param_count_; i++) { + SeekdbBind bind = {}; + SeekdbFieldType param_type = param_types_[i]; + if (param_type == SEEKDB_TYPE_NULL) { + null_flags.push_back(1); // true + bind.buffer_type = SEEKDB_TYPE_NULL; + bind.is_null = reinterpret_cast(&null_flags.back()); + } else if (param_type == SEEKDB_TYPE_STRING) { + const std::string& str_val = param_strings_[i]; + + // _id placeholders (CAST(? AS BINARY)) use VARBINARY_ID so C ABI right-pads to 512 bytes + SeekdbFieldType bind_type = (i < varbinary_id_flags.size() && varbinary_id_flags[i]) + ? SEEKDB_TYPE_VARBINARY_ID + : SEEKDB_TYPE_STRING; + string_buffers.push_back(std::vector(str_val.begin(), str_val.end())); + string_buffers.back().push_back('\0'); + + lengths.push_back(str_val.length()); + null_flags.push_back(0); // false + bind.buffer_type = bind_type; + bind.buffer = string_buffers.back().data(); + bind.buffer_length = str_val.length(); + bind.length = &lengths.back(); + bind.is_null = reinterpret_cast(&null_flags.back()); + } else if (param_type == SEEKDB_TYPE_LONGLONG) { + int_values.push_back(static_cast(param_numbers_[i])); + null_flags.push_back(0); // false + + bind.buffer_type = SEEKDB_TYPE_LONGLONG; + bind.buffer = &int_values.back(); + bind.buffer_length = sizeof(int64_t); + bind.is_null = reinterpret_cast(&null_flags.back()); + } else if (param_type == SEEKDB_TYPE_DOUBLE) { + double_values.push_back(param_numbers_[i]); + null_flags.push_back(0); // false + + bind.buffer_type = SEEKDB_TYPE_DOUBLE; + bind.buffer = &double_values.back(); + bind.buffer_length = sizeof(double); + bind.is_null = reinterpret_cast(&null_flags.back()); + } else if (param_type == SEEKDB_TYPE_TINY) { + bool_values.push_back(param_bools_[i] ? 1 : 0); + null_flags.push_back(0); // false + + bind.buffer_type = SEEKDB_TYPE_TINY; + bind.buffer = &bool_values.back(); + bind.buffer_length = sizeof(uint8_t); + bind.is_null = reinterpret_cast(&null_flags.back()); + } + + binds.push_back(bind); + } + + // IMPORTANT: Store buffers in member variables BEFORE building binds array + // This ensures pointers in binds remain valid after move + // Store buffers first to keep them alive during execution + string_buffers_ = std::move(string_buffers); + lengths_ = std::move(lengths); + null_flags_ = std::move(null_flags); + int_values_ = std::move(int_values); + double_values_ = std::move(double_values); + bool_values_ = std::move(bool_values); + + // Now rebuild binds array with pointers to member variables + // This is necessary because move() invalidates pointers in the original binds + binds.clear(); + size_t string_idx = 0; + size_t length_idx = 0; + size_t null_idx = 0; + size_t int_idx = 0; + size_t double_idx = 0; + size_t bool_idx = 0; + + for (uint32_t i = 0; i < param_count_; i++) { + SeekdbBind bind = {}; + SeekdbFieldType param_type = param_types_[i]; + SeekdbFieldType bind_type = param_type; + if (param_type == SEEKDB_TYPE_STRING && i < varbinary_id_flags.size() && varbinary_id_flags[i]) { + bind_type = SEEKDB_TYPE_VARBINARY_ID; + } + + if (param_type == SEEKDB_TYPE_NULL) { + bind.buffer_type = SEEKDB_TYPE_NULL; + bind.is_null = reinterpret_cast(&null_flags_[null_idx++]); + } else if (param_type == SEEKDB_TYPE_STRING) { + bind.buffer_type = bind_type; + bind.buffer = string_buffers_[string_idx].data(); + bind.buffer_length = string_buffers_[string_idx].size() - 1; // Exclude null terminator + bind.length = &lengths_[length_idx++]; + bind.is_null = reinterpret_cast(&null_flags_[null_idx++]); + string_idx++; + } else if (param_type == SEEKDB_TYPE_LONGLONG) { + bind.buffer_type = SEEKDB_TYPE_LONGLONG; + bind.buffer = &int_values_[int_idx++]; + bind.buffer_length = sizeof(int64_t); + bind.is_null = reinterpret_cast(&null_flags_[null_idx++]); + } else if (param_type == SEEKDB_TYPE_DOUBLE) { + bind.buffer_type = SEEKDB_TYPE_DOUBLE; + bind.buffer = &double_values_[double_idx++]; + bind.buffer_length = sizeof(double); + bind.is_null = reinterpret_cast(&null_flags_[null_idx++]); + } else if (param_type == SEEKDB_TYPE_TINY) { + bind.buffer_type = SEEKDB_TYPE_TINY; + bind.buffer = &bool_values_[bool_idx++]; + bind.buffer_length = sizeof(uint8_t); + bind.is_null = reinterpret_cast(&null_flags_[null_idx++]); + } + + binds.push_back(bind); + } + + binds_ = std::move(binds); + + // Use parameterized query API (C ABI layer handles parameter binding) + // Note: The underlying library will auto-detect VECTOR type based on column schema + // by preparing the statement first and checking column types + + // Check if this is a vector query + bool is_vector_query = (sql_.find("cosine_distance") != std::string::npos || + sql_.find("l2_distance") != std::string::npos || + sql_.find("inner_product") != std::string::npos); + + ret = seekdb_query_with_params( + conn_->handle, + sql_.c_str(), + &seekdb_result, + binds_.data(), + static_cast(binds_.size()) + ); + // Fallback: if seekdb_query_with_params returned success but *result null, try seekdb_store_result(handle). + if (ret == SEEKDB_SUCCESS && !seekdb_result && is_vector_query) { + SeekdbResult stored_result = seekdb_store_result(conn_->handle); + if (stored_result) { + seekdb_result = stored_result; + } + } + } else { + bool is_vector_query = (sql_.find("cosine_distance") != std::string::npos || + sql_.find("l2_distance") != std::string::npos || + sql_.find("inner_product") != std::string::npos); + (void)is_vector_query; + ret = seekdb_query(conn_->handle, sql_.c_str(), &seekdb_result); + } + + if (ret != SEEKDB_SUCCESS) { + // Use connection-specific error first, fallback to thread-local error + const char* error_msg = seekdb_error(conn_->handle); + if (!error_msg) { + error_msg = seekdb_last_error(); + } + std::string error_str = error_msg ? error_msg : "Query failed"; + SetError(error_str); + return; + } + + // If query succeeded but result is null, try to get stored result + // Note: For DML statements (INSERT/UPDATE/DELETE), result may be null but query succeeded + // This is normal for DML statements, we should create an empty result set + if (!seekdb_result) { + seekdb_result = seekdb_store_result(conn_->handle); + } + if (!seekdb_result) { + result_ = nullptr; + } else { + try { + result_ = new SeekdbResultWrapper(seekdb_result); + } catch (const std::bad_alloc& e) { + SetError("Memory allocation failed: " + std::string(e.what())); + return; + } catch (const std::exception& e) { + SetError("Exception in Execute: " + std::string(e.what())); + return; + } + } + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::HandleScope scope(env); + + // Handle queries with null result (empty result set) + // This can happen for: + // 1. DML statements (INSERT/UPDATE/DELETE) - normal, return empty result + // 2. SELECT queries with no matching rows - also normal, return empty result with columns + // Reference implementation creates empty result set in both cases + if (!result_) { + auto result_obj = Napi::Object::New(env); + auto columns = Napi::Array::New(env, 0); + auto rows = Napi::Array::New(env, 0); + result_obj.Set("columns", columns); + result_obj.Set("rows", rows); + deferred_.Resolve(result_obj); + return; + } + + // Build result object + auto result_obj = Napi::Object::New(env); + + // Set columns - ensure we have valid column names + auto columns = Napi::Array::New(env, result_->column_names.size()); + for (size_t i = 0; i < result_->column_names.size(); i++) { + // Ensure column name is not empty + std::string col_name = result_->column_names[i]; + if (col_name.empty()) { + col_name = "col_" + std::to_string(i); + } + columns.Set(i, Napi::String::New(env, col_name)); + } + result_obj.Set("columns", columns); + + // Validate result handle + if (!result_->result) { + deferred_.Reject(Napi::Error::New(env, "Result handle is null").Value()); + return; + } + + if (result_->column_count == 0) { + auto rows = Napi::Array::New(env, 0); + result_obj.Set("rows", rows); + deferred_.Resolve(result_obj); + return; + } + auto rows = Napi::Array::New(env, result_->row_count); + bool has_field_info = !result_->field_info.empty() && result_->field_info.size() == static_cast(result_->column_count); + for (int64_t i = 0; i < result_->row_count; i++) { + SeekdbRow row = seekdb_fetch_row(result_->result); + if (row) { + auto row_obj = Napi::Array::New(env, result_->column_count); + + for (int32_t j = 0; j < result_->column_count; j++) { + const std::string& col_name = (j < static_cast(result_->column_names.size())) ? result_->column_names[j] : ("col_" + std::to_string(j)); + bool row_is_null = seekdb_row_is_null(row, j); + // When C ABI reports null: try 2MB buffer first (long TEXT may be wrongly reported as null); if non-empty use it. If 2MB returns empty and str_len==0 treat as ""; if 2MB fails do not fall back to 1-byte (column may be long), set null. + if (row_is_null) { + size_t str_len = seekdb_row_get_string_len(row, j); + const size_t fallback_buf_size = 2 * 1024 * 1024; + std::vector buf(fallback_buf_size, 0); + int get_ret = seekdb_row_get_string(row, j, buf.data(), buf.size()); + if (get_ret == SEEKDB_SUCCESS && buf[0] != '\0') { + row_obj.Set(j, Napi::String::New(env, buf.data())); + } else if (get_ret == SEEKDB_SUCCESS && str_len == 0) { + row_obj.Set(j, Napi::String::New(env, "")); + } else if (get_ret == SEEKDB_SUCCESS) { + row_obj.Set(j, env.Null()); + } else { + // 2MB failed: do not try 1-byte (may be long content); set null + row_obj.Set(j, env.Null()); + } + } else { + // Use field type information if available for optimized type detection + if (has_field_info && result_->field_info[j]) { + SeekdbField* field = result_->field_info[j]; + int32_t field_type = field->type; + + // Map MySQL field type to appropriate getter + // Field types align with SeekdbFieldType enum values: + // SEEKDB_TYPE_TINY=1, SHORT=2, LONG=3, LONGLONG=4 + // SEEKDB_TYPE_FLOAT=5, DOUBLE=6 + // SEEKDB_TYPE_STRING=11, BLOB=12 + bool value_set = false; + + // Try integer types (TINY, SHORT, LONG, LONGLONG) - types 1-4 + if (field_type >= 1 && field_type <= 4) { + // For TINY (type 1), try boolean first if it makes sense + if (field_type == 1) { + bool bool_val; + if (seekdb_row_get_bool(row, j, &bool_val) == SEEKDB_SUCCESS) { + row_obj.Set(j, Napi::Boolean::New(env, bool_val)); + value_set = true; + } + } + // If boolean failed or not TINY, try int64 + if (!value_set) { + int64_t int_val; + if (seekdb_row_get_int64(row, j, &int_val) == SEEKDB_SUCCESS) { + row_obj.Set(j, Napi::Number::New(env, static_cast(int_val))); + value_set = true; + } + } + } + // Try floating point types (FLOAT, DOUBLE) - types 5-6 + else if (field_type == 5 || field_type == 6) { + double double_val; + if (seekdb_row_get_double(row, j, &double_val) == SEEKDB_SUCCESS) { + row_obj.Set(j, Napi::Number::New(env, double_val)); + value_set = true; + } + } + + // For STRING/BLOB types (11-12): string getter. For VECTOR (40/13): C ABI may return JSON string (vector_binary_to_json) or binary; return as string so SDK can JSON.parse, or fallback to parseEmbeddingBinaryString for binary. + if (!value_set || field_type == 11 || field_type == 12 || field_type == 40 || field_type == 13) { + size_t str_len = seekdb_row_get_string_len(row, j); + const size_t max_safe_len = 10 * 1024 * 1024; // 10MB cap to avoid OOM + const size_t fallback_buf_size = 2 * 1024 * 1024; // 2MB for long document/metadata + bool string_set = false; + // When C ABI returns valid length for STRING/BLOB/VECTOR (and not 0 for long content) + if (str_len != static_cast(-1) && str_len > 0 && str_len <= max_safe_len) { + std::vector buf(str_len + 1); + if (seekdb_row_get_string(row, j, buf.data(), buf.size()) == SEEKDB_SUCCESS) { + row_obj.Set(j, Napi::String::New(env, buf.data())); + string_set = true; + } + } + // When C ABI returns -1 or 0 for length (e.g. long TEXT/BLOB or wrong len): try large buffer for string-like columns + if (!string_set && (field_type == 11 || field_type == 12 || field_type == 40 || field_type == 13 || !value_set)) { + std::vector buf(fallback_buf_size, 0); + if (seekdb_row_get_string(row, j, buf.data(), buf.size()) == SEEKDB_SUCCESS) { + row_obj.Set(j, Napi::String::New(env, buf.data())); + string_set = true; + } + } + if (!string_set) { + row_obj.Set(j, env.Null()); + } + } + } else { + // Fallback: get string length first when available to support long TEXT/BLOB (e.g. 100KB document) + size_t str_len = seekdb_row_get_string_len(row, j); + const size_t max_safe_len = 10 * 1024 * 1024; // 10MB cap to avoid OOM + const size_t fallback_buf_size = 2 * 1024 * 1024; // 2MB when length unknown + if (str_len != static_cast(-1) && str_len <= max_safe_len) { + std::vector buf(str_len + 1, 0); + int ret = seekdb_row_get_string(row, j, buf.data(), buf.size()); + if (ret == SEEKDB_SUCCESS) { + row_obj.Set(j, Napi::String::New(env, buf.data())); + } else { + row_obj.Set(j, env.Null()); + } + } else if (str_len == static_cast(-1)) { + // Length unknown (e.g. long TEXT/BLOB): try large buffer so long document/metadata not truncated + std::vector buf(fallback_buf_size, 0); + int ret = seekdb_row_get_string(row, j, buf.data(), buf.size()); + if (ret == SEEKDB_SUCCESS) { + row_obj.Set(j, Napi::String::New(env, buf.data())); + } else { + row_obj.Set(j, env.Null()); + } + } else { + // Length > 10MB: use fixed buffer (e.g. for numeric/boolean columns or legacy path) + std::vector buf(4096, 0); + int ret = seekdb_row_get_string(row, j, buf.data(), buf.size()); + if (ret == SEEKDB_SUCCESS) { + std::string str_val(buf.data()); + if (!str_val.empty()) { + char* end_ptr = nullptr; + double num_val = std::strtod(str_val.c_str(), &end_ptr); + if (*end_ptr == '\0' && end_ptr != str_val.c_str()) { + if (num_val == static_cast(num_val)) { + row_obj.Set(j, Napi::Number::New(env, static_cast(static_cast(num_val)))); + } else { + row_obj.Set(j, Napi::Number::New(env, num_val)); + } + } else if (str_val == "true" || str_val == "1") { + row_obj.Set(j, Napi::Boolean::New(env, true)); + } else if (str_val == "false" || str_val == "0") { + row_obj.Set(j, Napi::Boolean::New(env, false)); + } else { + row_obj.Set(j, Napi::String::New(env, str_val)); + } + } else { + row_obj.Set(j, env.Null()); + } + } else { + row_obj.Set(j, env.Null()); + } + } + } + } + } + + rows.Set(i, row_obj); + } else { + break; + } + } + result_obj.Set("rows", rows); + deferred_.Resolve(result_obj); + } + + void OnError(const Napi::Error& e) override { + deferred_.Reject(e.Value()); + } + + private: + Napi::Promise::Deferred deferred_; + SeekdbConnection* conn_; + std::string sql_; + bool has_params_; + + // Pre-extracted parameter values (extracted in constructor on main thread) + uint32_t param_count_; + std::vector param_types_; + std::vector param_strings_; + std::vector param_numbers_; + std::vector param_bools_; + + // Buffer storage for parameter binding (kept alive during execution) + std::vector binds_; + std::vector> string_buffers_; + std::vector lengths_; + std::vector null_flags_; // Use uint8_t instead of bool (std::vector is specialized and can't take address) + std::vector int_values_; + std::vector double_values_; + std::vector bool_values_; // Use uint8_t instead of bool (std::vector is specialized and can't take address) + + SeekdbResultWrapper* result_; +}; + +// Main addon class +class SeekdbNodeAddon : public Napi::Addon { + public: + SeekdbNodeAddon(Napi::Env env, Napi::Object exports) { + // Database operations + DefineAddon(exports, { + // function open(db_dir?: string): Database + InstanceMethod("open", &SeekdbNodeAddon::open), + + // function open_with_service(db_dir?: string, port?: number): Database + InstanceMethod("open_with_service", &SeekdbNodeAddon::open_with_service), + + // function close_sync(database: Database): void + InstanceMethod("close_sync", &SeekdbNodeAddon::close_sync), + + // function connect(database: Database, database_name: string, autocommit: boolean): Connection + InstanceMethod("connect", &SeekdbNodeAddon::connect), + + // function disconnect(connection: Connection): void + InstanceMethod("disconnect", &SeekdbNodeAddon::disconnect), + + // function execute(connection: Connection, sql: string): Promise + InstanceMethod("execute", &SeekdbNodeAddon::execute), + }); + } + + private: + // function open(db_dir?: string): Database + Napi::Value open(const Napi::CallbackInfo& info) { + auto env = info.Env(); + + std::string db_dir = ""; + if (info.Length() > 0 && !info[0].IsUndefined() && !info[0].IsNull()) { + db_dir = info[0].As().Utf8Value(); + } + + // Call seekdb_open + int ret = seekdb_open(db_dir.empty() ? nullptr : db_dir.c_str()); + if (ret != SEEKDB_SUCCESS) { + const char* error = seekdb_last_error(); + throw Napi::Error::New(env, error ? error : "Failed to open database"); + } + + // Create database wrapper (just a marker) + auto db = new SeekdbDatabase(db_dir); + + return CreateExternal(env, DatabaseTypeTag, db); + } + + // function open_with_service(db_dir?: string, port?: number): Database + Napi::Value open_with_service(const Napi::CallbackInfo& info) { + auto env = info.Env(); + + std::string db_dir = ""; + int port = 0; // Default to embedded mode (port <= 0) + + if (info.Length() > 0 && !info[0].IsUndefined() && !info[0].IsNull()) { + db_dir = info[0].As().Utf8Value(); + } + + if (info.Length() > 1 && !info[1].IsUndefined() && !info[1].IsNull()) { + if (info[1].IsNumber()) { + port = info[1].As().Int32Value(); + } + } + + // Call seekdb_open_with_service + // If port > 0, runs in server mode; if port <= 0, runs in embedded mode + int ret = seekdb_open_with_service(db_dir.empty() ? nullptr : db_dir.c_str(), port); + if (ret != SEEKDB_SUCCESS) { + const char* error = seekdb_last_error(); + throw Napi::Error::New(env, error ? error : "Failed to open database with service"); + } + + // Create database wrapper (just a marker) + auto db = new SeekdbDatabase(db_dir); + + return CreateExternal(env, DatabaseTypeTag, db); + } + + // function close_sync(database: Database): void + Napi::Value close_sync(const Napi::CallbackInfo& info) { + auto env = info.Env(); + auto db = GetDatabaseFromExternal(env, info[0]); + + // Call seekdb_close (global close) + seekdb_close(); + + delete db; + + return env.Undefined(); + } + + // function connect(database: Database, database_name: string, autocommit: boolean): Connection + Napi::Value connect(const Napi::CallbackInfo& info) { + auto env = info.Env(); + // Validate database parameter (db is not used but needed for type checking) + (void)GetDatabaseFromExternal(env, info[0]); + std::string db_name = info[1].As().Utf8Value(); + bool autocommit = info[2].As().Value(); + + // Call seekdb_connect + SeekdbHandle handle = nullptr; + int ret = seekdb_connect(&handle, db_name.c_str(), autocommit); + if (ret != SEEKDB_SUCCESS) { + const char* error = seekdb_last_error(); + throw Napi::Error::New(env, error ? error : "Failed to connect"); + } + + auto conn = new SeekdbConnection(handle, db_name, autocommit); + + return CreateExternal(env, ConnectionTypeTag, conn); + } + + // function disconnect(connection: Connection): void + Napi::Value disconnect(const Napi::CallbackInfo& info) { + auto env = info.Env(); + auto conn = GetConnectionFromExternal(env, info[0]); + + // Manually close the connection before deleting to avoid double-free + // Set handle to nullptr so destructor won't try to close again + if (conn && conn->handle) { + seekdb_connect_close(conn->handle); + conn->handle = nullptr; + } + + // Connection cleanup is handled by destructor + delete conn; + + return env.Undefined(); + } + + // function execute(connection: Connection, sql: string, params?: any[]): Promise + Napi::Value execute(const Napi::CallbackInfo& info) { + auto env = info.Env(); + + if (info.Length() < 2) { + throw Napi::TypeError::New(env, "Expected connection and sql"); + } + + auto conn = GetConnectionFromExternal(env, info[0]); + std::string sql = info[1].As().Utf8Value(); + + // Check if parameters are provided + Napi::Array params; + bool has_params = false; + if (info.Length() >= 3 && !info[2].IsUndefined() && !info[2].IsNull()) { + if (info[2].IsArray()) { + params = info[2].As(); + if (params.Length() > 0) { + has_params = true; + } + } + } + + // Create promise + auto deferred = Napi::Promise::Deferred::New(env); + + // Create and queue async worker + ExecuteWorker* worker; + if (has_params) { + worker = new ExecuteWorker(deferred, conn, sql, params); + } else { + worker = new ExecuteWorker(deferred, conn, sql); + } + worker->Queue(); + + return deferred.Promise(); + } +}; + +NODE_API_ADDON(SeekdbNodeAddon) diff --git a/packages/seekdb/README.md b/packages/seekdb/README.md index 63818c3..f50c5cd 100644 --- a/packages/seekdb/README.md +++ b/packages/seekdb/README.md @@ -29,12 +29,13 @@ For complete usage, please refer to the official documentation. ## Installation -> Before using the SDK, you need to deploy seekdb. Please refer to the [official deployment documentation](https://www.oceanbase.ai/docs/deploy-overview/). - ```bash npm install seekdb @seekdb/default-embed ``` +- **Embedded mode**: No server required; use locally. Native addon is loaded on first use (optional dependency or on-demand download). Data is stored under the `path` you provide (e.g. `./seekdb.db`). +- **Server mode**: Deploy seekdb or OceanBase first; see [official deployment documentation](https://www.oceanbase.ai/docs/deploy-overview/). + ## Quick Start ```typescript @@ -73,6 +74,8 @@ console.log("query results", results); ### Client Connection +**Server mode**: + ```typescript import { SeekdbClient } from "seekdb"; @@ -87,6 +90,17 @@ const client = new SeekdbClient({ }); ``` +**Embedded mode**: + +```typescript +import { SeekdbClient } from "seekdb"; + +const client = new SeekdbClient({ + path: "./seekdb.db", + database: "test", +}); +``` + ### Create Collection If you don't specify an embedding function, the default embedding function will be used for vectorization. Please install `@seekdb/default-embed`. @@ -378,12 +392,14 @@ const collection = await client.createCollection({ ### Database Management -The `SeekdbAdminClient` allows you to manage databases (create, list, delete). +Use `AdminClient()` for database management. It returns a `SeekdbClient` instance. In **embedded mode** you only pass `path`; no database name is required. + +**Server mode**: ```typescript -import { SeekdbAdminClient } from "seekdb"; +import { AdminClient } from "seekdb"; -const adminClient = new SeekdbAdminClient({ +const admin = AdminClient({ host: "127.0.0.1", port: 2881, user: "root", @@ -392,15 +408,22 @@ const adminClient = new SeekdbAdminClient({ // tenant: "sys" }); -// Create a new database -await adminClient.createDatabase("new_database"); - -// List all databases -const databases = await adminClient.listDatabases(); +await admin.createDatabase("new_database"); +const databases = await admin.listDatabases(); +const db = await admin.getDatabase("new_database"); +await admin.deleteDatabase("new_database"); +await admin.close(); +``` -// Get database info -const db = await adminClient.getDatabase("new_database"); +**Embedded mode** (no server): -// Delete a database -await adminClient.deleteDatabase("new_database"); +```typescript +import { AdminClient } from "seekdb"; + +const admin = AdminClient({ path: "./seekdb.db" }); +await admin.createDatabase("new_database"); +const databases = await admin.listDatabases(); +const db = await admin.getDatabase("new_database"); +await admin.deleteDatabase("new_database"); +await admin.close(); ``` diff --git a/packages/seekdb/package.json b/packages/seekdb/package.json index 6aac6f7..88a7954 100644 --- a/packages/seekdb/package.json +++ b/packages/seekdb/package.json @@ -20,6 +20,7 @@ }, "scripts": { "build": "tsup", + "build:bindings": "cd ../bindings && node-gyp configure && node-gyp build", "dev": "tsup --watch", "test": "vitest", "type-check": "tsc --noEmit", @@ -42,9 +43,10 @@ "node": ">=20" }, "dependencies": { - "mysql2": "^3.11.5", - "node-addon-api": "^8.0.0", - "node-gyp": "^10.1.0" + "mysql2": "^3.11.5" + }, + "optionalDependencies": { + "@seekdb/js-bindings": "workspace:*" }, "peerDependencies": { "@seekdb/default-embed": "^1.1.1" diff --git a/packages/seekdb/src/admin-client.ts b/packages/seekdb/src/client-admin.ts similarity index 93% rename from packages/seekdb/src/admin-client.ts rename to packages/seekdb/src/client-admin.ts index 7e06d3d..f5baa14 100644 --- a/packages/seekdb/src/admin-client.ts +++ b/packages/seekdb/src/client-admin.ts @@ -11,7 +11,17 @@ export class SeekdbAdminClient { constructor(args: SeekdbAdminClientArgs) { this.tenant = args.tenant; // Initialize connection manager (no database specified for admin client) - this._internal = new InternalClient(args); + // Admin client requires host for remote server mode + if (!args.host) { + throw new Error( + "SeekdbAdminClient requires host parameter for remote server mode. " + + "For embedded mode, use AdminClient() factory function." + ); + } + this._internal = new InternalClient({ + ...args, + database: "information_schema", + }); } private isSeekdbMode(): boolean { diff --git a/packages/seekdb/src/client-base.ts b/packages/seekdb/src/client-base.ts new file mode 100644 index 0000000..28e6812 --- /dev/null +++ b/packages/seekdb/src/client-base.ts @@ -0,0 +1,582 @@ +/** + * Base client class for seekdb + * Contains common collection management and database admin methods shared by embedded and server clients + * Supports both v1 (table-only) and v2 (metadata table + collection_id) collection formats. + */ + +import { Collection } from "./collection.js"; +import { Database } from "./database.js"; +import { SQLBuilder } from "./sql-builder.js"; +import { + DEFAULT_TENANT, + DEFAULT_DISTANCE_METRIC, + DEFAULT_VECTOR_DIMENSION, + COLLECTION_V1_PREFIX, + extractDistance, + queryTableNames, + extractTableNamesFromResult, + validateCollectionName, + resolveEmbeddingFunction, + CollectionNames, + CollectionFieldNames, +} from "./utils.js"; +import { SeekdbValueError, InvalidCollectionError } from "./errors.js"; +import { + getEmbeddingFunction, + supportsPersistence, +} from "./embedding-function.js"; +import { + insertCollectionMetadata, + getCollectionMetadata, + deleteCollectionMetadata, + listCollectionMetadata, +} from "./metadata-manager.js"; +import type { + CreateCollectionOptions, + GetCollectionOptions, + IInternalClient, + DistanceMetric, + HNSWConfiguration, + Configuration, + FulltextAnalyzerConfig, + EmbeddingFunction, + Metadata, +} from "./types.js"; + +/** + * Base class for seekdb clients + * Provides common collection management functionality (v1 + v2 collections). + */ +export abstract class BaseSeekdbClient { + protected abstract readonly _internal: IInternalClient; + /** Optional internal client for admin ops (e.g. embedded uses information_schema). When set, admin methods use this. */ + protected _adminInternal?: IInternalClient; + /** Set by SeekdbClient facade so Collection can reference it (e.g. for fork). */ + protected _facade?: unknown; + + setFacade(facade: unknown): void { + this._facade = facade; + } + + /** + * Check if connected + */ + abstract isConnected(): boolean; + + /** + * Close connection + */ + abstract close(): Promise; + + // ==================== Collection Management ==================== + + /** + * Create a new collection (v2 format with metadata table). + * Supports Configuration (hnsw + fulltextConfig), HNSWConfiguration, and configuration=null with embedding function. + */ + async createCollection( + options: CreateCollectionOptions + ): Promise { + const { name, configuration, embeddingFunction } = options; + + validateCollectionName(name); + + if (await this.hasCollection(name)) { + throw new SeekdbValueError(`Collection already exists: ${name}`); + } + + let ef = embeddingFunction; + let hnsw: HNSWConfiguration | undefined; + let fulltextConfig: FulltextAnalyzerConfig | undefined; + + if (configuration) { + if ("hnsw" in configuration || "fulltextConfig" in configuration) { + const config = configuration as Configuration; + hnsw = config.hnsw; + fulltextConfig = config.fulltextConfig; + } else { + hnsw = configuration as HNSWConfiguration; + } + } + + let distance = hnsw?.distance ?? DEFAULT_DISTANCE_METRIC; + let dimension: number | undefined = undefined; + + if (ef === undefined) { + ef = await getEmbeddingFunction(); + } + + if (ef !== null) { + if ("dimension" in ef && typeof ef.dimension === "number") { + dimension = ef.dimension; + } else { + const testEmbeddings = await ef.generate(["seekdb"]); + dimension = testEmbeddings[0]?.length; + if (!dimension) { + throw new SeekdbValueError( + "Embedding function returned empty result when called with 'seekdb'" + ); + } + } + } + + if (configuration === null) { + if (ef === null || dimension === undefined) { + throw new SeekdbValueError( + "Cannot create collection: configuration is explicitly set to null and " + + "embedding_function is also null. Cannot determine dimension without either a configuration " + + "or an embedding function." + ); + } + dimension = dimension; + } else if (hnsw?.dimension !== undefined) { + if (dimension !== undefined && hnsw.dimension !== dimension) { + throw new SeekdbValueError( + `Configuration dimension (${hnsw.dimension}) does not match embedding function dimension (${dimension})` + ); + } + dimension = hnsw.dimension; + } else { + dimension = dimension ?? DEFAULT_VECTOR_DIMENSION; + } + + let embeddingFunctionMetadata: + | { name: string; properties: any } + | undefined; + if (supportsPersistence(ef)) { + embeddingFunctionMetadata = { name: ef.name, properties: ef.getConfig() }; + } + + const collectionId = await insertCollectionMetadata(this._internal, name, { + configuration: { + hnsw: { dimension, distance }, + fulltextConfig, + }, + embeddingFunction: embeddingFunctionMetadata, + }); + + const sql = SQLBuilder.buildCreateTable( + name, + dimension, + distance, + undefined, + collectionId, + fulltextConfig + ); + + try { + await this._internal.execute(sql); + } catch (error) { + try { + await deleteCollectionMetadata(this._internal, name); + } catch { + // Ignore cleanup errors + } + throw error; + } + + return new Collection({ + name, + dimension, + distance, + embeddingFunction: ef ?? undefined, + internalClient: this._internal, + client: this._facade as any, + collectionId, + }); + } + + /** + * Extract metadata from v1 table COMMENT (JSON string). + */ + private static extractMetadataFromComment( + createTable: string + ): Metadata | undefined { + const commentMatch = createTable.match( + /COMMENT\s*=\s*'([^']*(?:''[^']*)*)'/ + ); + if (!commentMatch) return undefined; + try { + const commentValue = commentMatch[1].replace(/''/g, "'"); + return JSON.parse(commentValue) as Metadata; + } catch { + return undefined; + } + } + + /** + * Get an existing collection. Tries v2 (metadata table) first, then v1 (table-only). + */ + async getCollection(options: GetCollectionOptions): Promise { + const { name, embeddingFunction } = options; + + validateCollectionName(name); + + let dimension: number; + let distance: DistanceMetric; + let collectionId: string | undefined; + let embeddingFunctionConfig: { name: string; properties: any } | undefined; + let collectionMetadata: Metadata | undefined; + + const metadata = await getCollectionMetadata(this._internal, name); + + if (metadata) { + const { + collectionId: cId, + settings: { + embeddingFunction: embeddingFunctionMeta, + configuration, + } = {}, + } = metadata; + + const sql = SQLBuilder.buildShowTable(name, cId); + const result = await this._internal.execute(sql); + + if (!result || result.length === 0) { + throw new InvalidCollectionError( + `Collection metadata exists but table not found: ${name}` + ); + } + + let hnsw: HNSWConfiguration | undefined; + if (configuration) { + if ("hnsw" in configuration) { + hnsw = (configuration as Configuration).hnsw; + } else { + hnsw = configuration as HNSWConfiguration; + } + } + + dimension = hnsw?.dimension ?? DEFAULT_VECTOR_DIMENSION; + distance = hnsw?.distance ?? DEFAULT_DISTANCE_METRIC; + collectionId = cId; + embeddingFunctionConfig = embeddingFunctionMeta; + } else { + const sql = SQLBuilder.buildShowTable(name); + const result = await this._internal.execute(sql); + + if (!result || result.length === 0) { + throw new InvalidCollectionError(`Collection not found: ${name}`); + } + + const descSql = SQLBuilder.buildDescribeTable(name); + const schema = await this._internal.execute(descSql); + + if (!schema) { + throw new InvalidCollectionError( + `Unable to retrieve schema for collection: ${name}` + ); + } + + const embeddingField = schema.find( + (row: any) => row.Field === CollectionFieldNames.EMBEDDING + ); + if (!embeddingField) { + throw new InvalidCollectionError( + `Collection ${name} does not have embedding field` + ); + } + + const match = (embeddingField as any).Type?.match?.(/VECTOR\((\d+)\)/i); + if (!match) { + throw new InvalidCollectionError( + `Invalid embedding type: ${(embeddingField as any).Type}` + ); + } + + dimension = parseInt(match[1], 10); + distance = DEFAULT_DISTANCE_METRIC; + + try { + const createTableSql = SQLBuilder.buildShowCreateTable(name); + const createTableResult = await this._internal.execute(createTableSql); + + if (createTableResult && createTableResult.length > 0) { + const createStmt = + (createTableResult[0] as any)["Create Table"] || + (createTableResult[0] as any)["create table"] || + ""; + const distanceMatch = createStmt.match( + /with\s*\([^)]*distance\s*=\s*['"]?(\w+)['"]?/i + ); + if (distanceMatch) { + const parsed = distanceMatch[1].toLowerCase(); + if ( + parsed === "l2" || + parsed === "cosine" || + parsed === "inner_product" || + parsed === "ip" + ) { + distance = ( + parsed === "ip" ? "inner_product" : parsed + ) as DistanceMetric; + } + } + collectionMetadata = + BaseSeekdbClient.extractMetadataFromComment(createStmt); + } + } catch { + // Use default distance + } + } + + const ef = await resolveEmbeddingFunction( + embeddingFunctionConfig, + embeddingFunction + ); + + return new Collection({ + name, + dimension, + distance, + embeddingFunction: ef, + metadata: collectionMetadata, + internalClient: this._internal, + client: this._facade as any, + collectionId, + }); + } + + /** + * List all collections. Returns v2 collections from metadata table, then v1 (deduplicated). + * @param config.withEmbeddingFunction - If false, returned collections will not have embedding function loaded. Default true. + */ + async listCollections( + config: { withEmbeddingFunction?: boolean } = { + withEmbeddingFunction: true, + } + ): Promise { + const { withEmbeddingFunction } = config; + const collections: Collection[] = []; + const collectionNames = new Set(); + + const v2Metadata = await listCollectionMetadata(this._internal); + + for (const metadata of v2Metadata) { + try { + const collection = await this.getCollection({ + name: metadata.collectionName, + embeddingFunction: withEmbeddingFunction === false ? null : undefined, + }); + collections.push(collection); + collectionNames.add(metadata.collectionName); + } catch { + continue; + } + } + + const prefix = COLLECTION_V1_PREFIX; + const result = await queryTableNames(this._internal, prefix, true); + + if (result && result.length > 0) { + const tableNames = extractTableNamesFromResult(result, prefix); + + for (const tableName of tableNames) { + const collectionName = + CollectionNames.extractCollectionName(tableName) ?? + tableName.substring(prefix.length); + if (!collectionName || collectionNames.has(collectionName)) continue; + + try { + const collection = await this.getCollection({ + name: collectionName, + embeddingFunction: + withEmbeddingFunction === false ? null : undefined, + }); + collections.push(collection); + } catch { + continue; + } + } + } + + return collections; + } + + /** + * Delete a collection. For v2: drop table and metadata; for v1: drop table only. + */ + async deleteCollection(name: string): Promise { + validateCollectionName(name); + + if (!(await this.hasCollection(name))) { + throw new SeekdbValueError(`Collection not found: ${name}`); + } + + const metadata = await getCollectionMetadata(this._internal, name); + + if (metadata) { + const sql = SQLBuilder.buildDropTable(name, metadata.collectionId); + await this._internal.execute(sql); + await deleteCollectionMetadata(this._internal, name); + } else { + const sql = SQLBuilder.buildDropTable(name); + await this._internal.execute(sql); + } + } + + /** + * Check if collection exists. Checks v2 metadata first, then v1 table. + */ + async hasCollection(name: string): Promise { + if (!name || typeof name !== "string") return false; + + const metadata = await getCollectionMetadata(this._internal, name); + if (metadata) return true; + + const sql = SQLBuilder.buildShowTable(name); + const result = await this._internal.execute(sql); + return result !== null && result.length > 0; + } + + /** + * Get or create collection + */ + async getOrCreateCollection( + options: CreateCollectionOptions + ): Promise { + const { name } = options; + + // Try to get existing collection + try { + return await this.getCollection({ + name, + // Pass undefined (not null) so getCollection can load default embedding function if needed + embeddingFunction: options.embeddingFunction, + }); + } catch (error) { + const isNotFound = + (error instanceof SeekdbValueError && + error.message.includes("not found")) || + (error instanceof InvalidCollectionError && + error.message.includes("not found")); + if (isNotFound) { + return await this.createCollection(options); + } + throw error; + } + } + + /** + * Count collections + */ + async countCollection(): Promise { + const collections = await this.listCollections(); + return collections.length; + } + + // ==================== Database Management (admin) ==================== + // Explicit createDatabase: no auto-create on connect. Aligns with server and pyseekdb. + + /** + * Create database (explicit; connect does not auto-create). + * Embedded client uses built-in admin connection (information_schema); user does not specify it. + */ + async createDatabase( + name: string, + tenant: string = DEFAULT_TENANT + ): Promise { + if (!name || typeof name !== "string") { + throw new SeekdbValueError("Database name must be a non-empty string"); + } + const internal = this._adminInternal ?? this._internal; + const sql = `CREATE DATABASE IF NOT EXISTS \`${name}\``; + await internal.execute(sql); + } + + /** + * Get database metadata. + */ + async getDatabase( + name: string, + tenant: string = DEFAULT_TENANT + ): Promise { + if (!name || typeof name !== "string") { + throw new SeekdbValueError("Database name must be a non-empty string"); + } + const sql = + "SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?"; + const rows = await this._internal.execute(sql, [name]); + if (!rows || rows.length === 0) { + throw new SeekdbValueError(`Database not found: ${name}`); + } + const row = rows[0] as Record; + const schemaName = + (row.SCHEMA_NAME as string) ?? (row.schema_name as string) ?? ""; + const charset = + (row.DEFAULT_CHARACTER_SET_NAME as string) ?? + (row.default_character_set_name as string) ?? + ""; + const collation = + (row.DEFAULT_COLLATION_NAME as string) ?? + (row.default_collation_name as string) ?? + ""; + return new Database(schemaName, charset, collation); + } + + /** + * Delete database. + */ + async deleteDatabase( + name: string, + tenant: string = DEFAULT_TENANT + ): Promise { + if (!name || typeof name !== "string") { + throw new SeekdbValueError("Database name must be a non-empty string"); + } + const internal = this._adminInternal ?? this._internal; + const sql = `DROP DATABASE IF EXISTS \`${name}\``; + await internal.execute(sql); + } + + /** + * List databases. + */ + async listDatabases( + limit?: number, + offset?: number, + tenant: string = DEFAULT_TENANT + ): Promise { + if (limit !== undefined && (!Number.isInteger(limit) || limit < 0)) { + throw new SeekdbValueError("limit must be a non-negative integer"); + } + if (offset !== undefined && (!Number.isInteger(offset) || offset < 0)) { + throw new SeekdbValueError("offset must be a non-negative integer"); + } + const internal = this._adminInternal ?? this._internal; + let sql = + "SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA"; + const params: unknown[] = []; + if (limit !== undefined) { + if (offset !== undefined) { + sql += " LIMIT ?, ?"; + params.push(offset, limit); + } else { + sql += " LIMIT ?"; + params.push(limit); + } + } + const rows = await internal.execute( + sql, + params.length > 0 ? params : undefined + ); + const databases: Database[] = []; + if (rows) { + for (const row of rows) { + const r = row as Record; + const schemaName = + (r.SCHEMA_NAME as string) ?? (r.schema_name as string) ?? ""; + const charset = + (r.DEFAULT_CHARACTER_SET_NAME as string) ?? + (r.default_character_set_name as string) ?? + ""; + const collation = + (r.DEFAULT_COLLATION_NAME as string) ?? + (r.default_collation_name as string) ?? + ""; + databases.push(new Database(schemaName, charset, collation)); + } + } + return databases; + } +} diff --git a/packages/seekdb/src/client-embedded.ts b/packages/seekdb/src/client-embedded.ts new file mode 100644 index 0000000..21c0c49 --- /dev/null +++ b/packages/seekdb/src/client-embedded.ts @@ -0,0 +1,54 @@ +/** + * seekdb Client - Embedded mode (local native addon) + * Note: Requires native addon (similar to pylibseekdb in Python) + */ + +import { InternalEmbeddedClient } from "./internal-client-embedded.js"; +import { BaseSeekdbClient } from "./client-base.js"; +import { DEFAULT_DATABASE, ADMIN_DATABASE } from "./utils.js"; +import type { SeekdbClientArgs } from "./types.js"; +import * as path from "node:path"; + +/** + * seekdb Client for embedded mode (local native addon) + * Admin operations (createDatabase, listDatabases, getDatabase, deleteDatabase) use built-in + * admin connection (information_schema); user does not specify it. + */ +export class SeekdbEmbeddedClient extends BaseSeekdbClient { + protected readonly _internal: InternalEmbeddedClient; + protected readonly _path: string; + protected readonly _database: string; + + constructor(args: SeekdbClientArgs) { + super(); + if (!args.path) { + throw new Error( + "SeekdbEmbeddedClient requires path parameter for embedded mode." + ); + } + this._path = path.resolve(args.path); + this._database = args.database ?? DEFAULT_DATABASE; + this._internal = new InternalEmbeddedClient({ + path: this._path, + database: this._database, + }); + this._adminInternal = new InternalEmbeddedClient({ + path: this._path, + database: ADMIN_DATABASE, + }); + } + + /** + * Check if connected + */ + isConnected(): boolean { + return this._internal.isConnected(); + } + + /** + * Close connection + */ + async close(): Promise { + await this._internal.close(); + } +} diff --git a/packages/seekdb/src/client-server.ts b/packages/seekdb/src/client-server.ts new file mode 100644 index 0000000..54f3c0f --- /dev/null +++ b/packages/seekdb/src/client-server.ts @@ -0,0 +1,42 @@ +/** + * seekdb Client - Remote server mode (MySQL protocol) + * Supports both seekdb Server and OceanBase Server + */ + +import { InternalClient } from "./internal-client.js"; +import { BaseSeekdbClient } from "./client-base.js"; +import { DEFAULT_DATABASE } from "./utils.js"; +import type { SeekdbClientArgs } from "./types.js"; + +/** + * seekdb Client for remote server connections + */ +export class SeekdbServerClient extends BaseSeekdbClient { + protected readonly _internal: InternalClient; + protected readonly _database: string; + + constructor(args: SeekdbClientArgs) { + super(); + if (!args.host) { + throw new Error( + "SeekdbServerClient requires host parameter for remote server mode." + ); + } + this._database = args.database ?? DEFAULT_DATABASE; + this._internal = new InternalClient(args); + } + + /** + * Check if connected + */ + isConnected(): boolean { + return this._internal.isConnected(); + } + + /** + * Close connection + */ + async close(): Promise { + await this._internal.close(); + } +} diff --git a/packages/seekdb/src/client.ts b/packages/seekdb/src/client.ts index cac30ca..a961875 100644 --- a/packages/seekdb/src/client.ts +++ b/packages/seekdb/src/client.ts @@ -1,67 +1,56 @@ /** - * seekdb Client - Remote server mode (MySQL protocol) - * Supports both seekdb Server and OceanBase Server + * seekdb Client - Unified entry point for both embedded and remote server modes + * Automatically selects the appropriate implementation based on parameters: + * - If path is provided, uses embedded mode (SeekdbEmbeddedClient) + * - If host is provided, uses remote server mode (SeekdbServerClient) */ -import type { RowDataPacket } from "mysql2/promise"; -import { Collection } from "./collection.js"; -import { InternalClient } from "./internal-client.js"; -import { SQLBuilder } from "./sql-builder.js"; -import { SeekdbValueError, InvalidCollectionError } from "./errors.js"; -import { - getEmbeddingFunction, - supportsPersistence, -} from "./embedding-function.js"; -import { - CollectionFieldNames, - DEFAULT_DISTANCE_METRIC, - DEFAULT_VECTOR_DIMENSION, - COLLECTION_V1_PREFIX, - resolveEmbeddingFunction, - validateCollectionName, - CollectionNames, -} from "./utils.js"; -import { - insertCollectionMetadata, - getCollectionMetadata, - deleteCollectionMetadata, - listCollectionMetadata, - CollectionMetadata, - METADATA_TABLE_NAME, -} from "./metadata-manager.js"; +import { SeekdbServerClient } from "./client-server.js"; +import { SeekdbEmbeddedClient } from "./client-embedded.js"; import type { SeekdbClientArgs, CreateCollectionOptions, GetCollectionOptions, - DistanceMetric, - Configuration, - HNSWConfiguration, - FulltextAnalyzerConfig, - ConfigurationParam, } from "./types.js"; +import type { Collection } from "./collection.js"; +import type { Database } from "./database.js"; /** - * seekdb Client for remote server connections + * seekdb Client - Unified client for both embedded and remote server modes + * + * This class acts as a facade that delegates to either SeekdbEmbeddedClient + * or SeekdbServerClient based on the provided parameters. */ export class SeekdbClient { - private _internal: InternalClient; + private _delegate: SeekdbServerClient | SeekdbEmbeddedClient; constructor(args: SeekdbClientArgs) { - this._internal = new InternalClient(args); + const { path: dbPath, host } = args; + + if (dbPath !== undefined) { + this._delegate = new SeekdbEmbeddedClient(args); + } else if (host !== undefined) { + this._delegate = new SeekdbServerClient(args); + } else { + throw new Error( + "SeekdbClient requires either 'path' parameter for embedded mode or 'host' parameter for remote server mode." + ); + } + (this._delegate as { setFacade?(f: unknown): void }).setFacade?.(this); } /** * Check if connected */ isConnected(): boolean { - return this._internal.isConnected(); + return this._delegate.isConnected(); } /** * Close connection */ async close(): Promise { - await this._internal.close(); + await this._delegate.close(); } // ==================== Collection Management ==================== @@ -72,264 +61,14 @@ export class SeekdbClient { async createCollection( options: CreateCollectionOptions ): Promise { - const { name, configuration, embeddingFunction } = options; - - // Validate collection name - validateCollectionName(name); - - let ef = embeddingFunction; - let hnsw: HNSWConfiguration | undefined; - let fulltextConfig: FulltextAnalyzerConfig | undefined; - - // Extract HNSW and fulltext config - if (configuration) { - if ("hnsw" in configuration || "fulltextConfig" in configuration) { - const config = configuration as Configuration; - hnsw = config.hnsw; - fulltextConfig = config.fulltextConfig; - } else { - hnsw = configuration as HNSWConfiguration; - } - } - - let distance = hnsw?.distance ?? DEFAULT_DISTANCE_METRIC; - - // Calculate actual dimension from embedding function if provided - let dimension: number | undefined; - - // Handle embedding function: undefined means use default, null means no EF - if (ef === undefined) { - ef = await getEmbeddingFunction(); - } - - // If embedding function exists, try to get dimension - if (ef !== null) { - // Priority 1: Read dimension property (avoid model initialization) - if ("dimension" in ef && typeof ef.dimension === "number") { - dimension = ef.dimension; - } else { - // Priority 2: Call generate to calculate dimension - const testEmbeddings = await ef.generate(["seekdb"]); - dimension = testEmbeddings[0]?.length; - if (!dimension) { - throw new SeekdbValueError( - "Embedding function returned empty result when called with 'seekdb'" - ); - } - } - } - - // Determine final dimension based on configuration and embedding function - if (configuration === null) { - // configuration=null: MUST have embedding function to infer dimension - if (ef === null || dimension === undefined) { - throw new SeekdbValueError( - "Cannot create collection: configuration is explicitly set to null and " + - "embedding_function is also null. Cannot determine dimension without either a configuration " + - "or an embedding function. Please either:\n" + - " 1. Provide a configuration with dimension specified (e.g., { dimension: 128, distance: 'cosine' }), or\n" + - " 2. Provide an embeddingFunction to calculate dimension automatically, or\n" + - " 3. Do not set configuration=null (use default configuration)." - ); - } - } else if (hnsw?.dimension !== undefined) { - // configuration has explicit dimension - if (dimension !== undefined && hnsw.dimension !== dimension) { - throw new SeekdbValueError( - `Configuration dimension (${hnsw.dimension}) does not match embedding function dimension (${dimension})` - ); - } - dimension = hnsw.dimension; - } else { - // configuration has no dimension: use dimension or default - dimension = dimension ?? DEFAULT_VECTOR_DIMENSION; - } - - // Prepare embedding function metadata (only if ef supports persistence) - let embeddingFunctionMetadata: - | { name: string; properties: any } - | undefined; - if (supportsPersistence(ef)) { - embeddingFunctionMetadata = { name: ef.name, properties: ef.getConfig() }; - } - - // Insert metadata and get collection_id - const collectionId = await insertCollectionMetadata(this._internal, name, { - configuration: { - hnsw: { dimension, distance }, - fulltextConfig, - }, - embeddingFunction: embeddingFunctionMetadata, - }); - - // Create table using SQLBuilder with collection_id (v2 format) - const sql = SQLBuilder.buildCreateTable( - name, - dimension, - distance, - undefined, - collectionId, - fulltextConfig - ); - - try { - await this._internal.execute(sql); - } catch (error) { - // If table creation fails, try to clean up metadata - try { - await deleteCollectionMetadata(this._internal, name); - } catch (cleanupError) { - // Ignore cleanup errors - } - throw error; - } - - return new Collection({ - name, - dimension, - distance, - embeddingFunction: ef ?? undefined, - internalClient: this._internal, - client: this, - collectionId, - }); + return this._delegate.createCollection(options); } /** * Get an existing collection */ async getCollection(options: GetCollectionOptions): Promise { - const { name, embeddingFunction } = options; - - // Variables to store collection info - let dimension: number; - let distance: DistanceMetric; - let collectionId: string | undefined; - - let embeddingFunctionConfig: - | CollectionMetadata["settings"]["embeddingFunction"] - | undefined; - - // Try v2 format first (check metadata table) - const metadata = await getCollectionMetadata(this._internal, name); - - if (metadata) { - // v2 collection found - extract from metadata - const { - collectionId: cId, - settings: { - embeddingFunction: embeddingFunctionMeta, - configuration, - } = {}, - } = metadata; - - // Verify table exists - const sql = SQLBuilder.buildShowTable(name, cId); - const result = await this._internal.execute(sql); - - if (!result || result.length === 0) { - throw new InvalidCollectionError( - `Collection metadata exists but table not found: ${name}` - ); - } - - let hnsw: HNSWConfiguration | undefined; - if (configuration) { - if ("hnsw" in configuration) { - hnsw = configuration.hnsw; - } else { - hnsw = configuration as HNSWConfiguration; - } - } - - dimension = hnsw?.dimension ?? DEFAULT_VECTOR_DIMENSION; - distance = hnsw?.distance ?? DEFAULT_DISTANCE_METRIC; - collectionId = cId; - embeddingFunctionConfig = embeddingFunctionMeta; - } else { - // Fallback to v1 format - extract from table schema - const sql = SQLBuilder.buildShowTable(name); - const result = await this._internal.execute(sql); - - if (!result || result.length === 0) { - throw new InvalidCollectionError(`Collection not found: ${name}`); - } - - // Get table schema to extract dimension and distance - const descSql = SQLBuilder.buildDescribeTable(name); - const schema = await this._internal.execute(descSql); - - if (!schema) { - throw new InvalidCollectionError( - `Unable to retrieve schema for collection: ${name}` - ); - } - - // Parse embedding field to get dimension - const embeddingField = schema.find( - (row: any) => row.Field === CollectionFieldNames.EMBEDDING - ); - if (!embeddingField) { - throw new InvalidCollectionError( - `Collection ${name} does not have embedding field` - ); - } - - // Parse VECTOR(dimension) format - const match = embeddingField.Type.match(/VECTOR\((\d+)\)/i); - if (!match) { - throw new InvalidCollectionError( - `Invalid embedding type: ${embeddingField.Type}` - ); - } - - dimension = parseInt(match[1], 10); - - // Extract distance from CREATE TABLE statement - distance = DEFAULT_DISTANCE_METRIC; - try { - const createTableSql = SQLBuilder.buildShowCreateTable(name); - const createTableResult = await this._internal.execute(createTableSql); - - if (createTableResult && createTableResult.length > 0) { - const createStmt = - (createTableResult[0] as any)["Create Table"] || ""; - // Match: with(distance=value, ...) where value can be l2, cosine, inner_product, or ip - const distanceMatch = createStmt.match( - /with\s*\([^)]*distance\s*=\s*['"]?(\w+)['"]?/i - ); - if (distanceMatch) { - const parsedDistance = distanceMatch[1].toLowerCase(); - if ( - parsedDistance === "l2" || - parsedDistance === "cosine" || - parsedDistance === "inner_product" || - parsedDistance === "ip" - ) { - distance = parsedDistance as DistanceMetric; - } - } - } - } catch (error) { - // If extraction fails, use default distance - } - } - - // Unified embedding function resolution - const ef = await resolveEmbeddingFunction( - embeddingFunctionConfig, - embeddingFunction - ); - - return new Collection({ - name, - dimension, - distance, - embeddingFunction: ef, - internalClient: this._internal, - client: this, - collectionId, - }); + return this._delegate.getCollection(options); } /** @@ -343,134 +82,21 @@ export class SeekdbClient { withEmbeddingFunction: true, } ): Promise { - const { withEmbeddingFunction } = config; - const collections: Collection[] = []; - const collectionNames = new Set(); - - // 1. Get v2 collections from metadata table - const v2Metadata = await listCollectionMetadata(this._internal); - - for (const metadata of v2Metadata) { - try { - const collection = await this.getCollection({ - name: metadata.collectionName, - embeddingFunction: !withEmbeddingFunction ? null : undefined, - }); - collections.push(collection); - collectionNames.add(metadata.collectionName); - } catch (error) { - // Skip if we can't get collection info - continue; - } - } - - // 2. Get v1 collections - const sql = `SHOW TABLES LIKE '${COLLECTION_V1_PREFIX}%'`; - let result: RowDataPacket[] | null = null; - - try { - result = await this._internal.execute(sql); - } catch (error) { - // Fallback: try to query information_schema - try { - // Get current database name - const dbResult = await this._internal.execute("SELECT DATABASE()"); - if (dbResult && dbResult.length > 0) { - const dbName = - (dbResult[0] as any)["DATABASE()"] || Object.values(dbResult[0])[0]; - if (dbName) { - result = await this._internal.execute( - `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${dbName}' AND TABLE_NAME LIKE '${COLLECTION_V1_PREFIX}%'` - ); - } - } - } catch (fallbackError) { - // If fallback also fails, continue with v2 collections only - } - } - - if (result) { - for (const row of result) { - // Extract table name - handle both SHOW TABLES format and information_schema format - let tableName: string; - if ((row as any).TABLE_NAME) { - // information_schema format - tableName = (row as any).TABLE_NAME; - } else { - // SHOW TABLES format - get first value - tableName = Object.values(row)[0] as string; - } - - // Double check prefix although SQL filter should handle it - const collectionName = - CollectionNames.extractCollectionName(tableName) || ""; - - // Skip if already added as v2 collection - if (collectionName && collectionNames.has(collectionName)) { - continue; - } - - try { - // Fetch full collection details - const collection = await this.getCollection({ - name: collectionName, - embeddingFunction: !withEmbeddingFunction ? null : undefined, - }); - collections.push(collection); - } catch (error) { - // Skip if we can't get collection info - continue; - } - } - } - - return collections; + return this._delegate.listCollections(config); } /** * Delete a collection */ async deleteCollection(name: string): Promise { - // Check if collection exists first - const exists = await this.hasCollection(name); - if (!exists) { - throw new Error(`Collection '${name}' does not exist`); - } - - // Check if it's a v2 collection - const metadata = await getCollectionMetadata(this._internal, name); - - if (metadata) { - // v2 collection - delete both table and metadata - const { collectionId } = metadata; - - // Delete table - const sql = SQLBuilder.buildDropTable(name, collectionId); - await this._internal.execute(sql); - - // Delete metadata - await deleteCollectionMetadata(this._internal, name); - } else { - // v1 collection - delete table only - const sql = SQLBuilder.buildDropTable(name); - await this._internal.execute(sql); - } + return this._delegate.deleteCollection(name); } /** * Check if collection exists */ async hasCollection(name: string): Promise { - // Check v2 format first (metadata table) - const metadata = await getCollectionMetadata(this._internal, name); - if (metadata) { - return true; - } - - // Fallback to v1 format - const sql = SQLBuilder.buildShowTable(name); - const result = await this._internal.execute(sql); - return result !== null && result.length > 0; + return this._delegate.hasCollection(name); } /** @@ -479,65 +105,36 @@ export class SeekdbClient { async getOrCreateCollection( options: CreateCollectionOptions ): Promise { - if (await this.hasCollection(options.name)) { - return this.getCollection(options); - } - return this.createCollection(options); + return this._delegate.getOrCreateCollection(options); } /** * Count collections */ async countCollection(): Promise { - const collectionNames = new Set(); - - // 1. Count v2 collections from metadata table - const v2Metadata = await listCollectionMetadata(this._internal); - const v2Count = v2Metadata.length; + return this._delegate.countCollection(); + } - // 2. Count v1 collections - let v1Count = 0; - const sql = `SELECT collection_name as TABLE_NAME FROM ${METADATA_TABLE_NAME}`; - let result: RowDataPacket[] | null = null; + // ==================== Database Management (admin) ==================== + // Explicit createDatabase: no auto-create on connect. Aligns with server and pyseekdb. - try { - result = await this._internal.execute(sql); - } catch (error) { - // Fallback: try to query information_schema - try { - const dbResult = await this._internal.execute("SELECT DATABASE()"); - if (dbResult && dbResult.length > 0) { - const dbName = - (dbResult[0] as any)["DATABASE()"] || Object.values(dbResult[0])[0]; - if (dbName) { - result = await this._internal.execute( - `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${dbName}' AND TABLE_NAME LIKE '${COLLECTION_V1_PREFIX}%'` - ); - } - } - } catch (fallbackError) { - // If fallback also fails, continue with v2 count only - } - } + async createDatabase(name: string, tenant?: string): Promise { + return this._delegate.createDatabase(name, tenant); + } - if (result) { - for (const row of result) { - // Extract table name - let tableName: string; - if ((row as any).TABLE_NAME) { - tableName = (row as any).TABLE_NAME; - } else { - tableName = Object.values(row)[0] as string; - } + async getDatabase(name: string, tenant?: string): Promise { + return this._delegate.getDatabase(name, tenant); + } - const collectionName = CollectionNames.extractCollectionName(tableName); - // Only count if not already in v2 - if (collectionName && !collectionNames.has(collectionName)) { - v1Count++; - } - } - } + async deleteDatabase(name: string, tenant?: string): Promise { + return this._delegate.deleteDatabase(name, tenant); + } - return v2Count + v1Count; + async listDatabases( + limit?: number, + offset?: number, + tenant?: string + ): Promise { + return this._delegate.listDatabases(limit, offset, tenant); } } diff --git a/packages/seekdb/src/collection.ts b/packages/seekdb/src/collection.ts index ff3f010..6e6b961 100644 --- a/packages/seekdb/src/collection.ts +++ b/packages/seekdb/src/collection.ts @@ -2,10 +2,16 @@ * Collection class - represents a collection of documents with vector embeddings */ -import type { InternalClient } from "./internal-client.js"; +import type { IInternalClient } from "./types.js"; import { SQLBuilder } from "./sql-builder.js"; import { SeekdbValueError } from "./errors.js"; -import { CollectionFieldNames, CollectionNames } from "./utils.js"; +import { + CollectionFieldNames, + CollectionNames, + normalizeValue, + parseEmbeddingBinary, + parseEmbeddingBinaryString, +} from "./utils.js"; import { FilterBuilder, SearchFilterCondition } from "./filters.js"; import { CollectionMetadata, @@ -30,7 +36,7 @@ import type { CollectionContext, ForkOptions, } from "./types.js"; -import { SeekdbClient } from "./client.js"; +import type { SeekdbClient } from "./client.js"; /** * Collection - manages a collection of documents with embeddings @@ -42,8 +48,8 @@ export class Collection { readonly embeddingFunction?: EmbeddingFunction; readonly metadata?: Metadata; readonly collectionId?: string; // v2 format collection ID - readonly client: SeekdbClient; - #client: InternalClient; + readonly client?: SeekdbClient; + #client: IInternalClient; constructor(config: CollectionConfig) { this.name = config.name; @@ -100,7 +106,9 @@ export class Collection { const upperSql = cleanSql.toUpperCase(); // Must start with SELECT - if (!upperSql.startsWith("SELECT")) { + // For hybrid search, DBMS_HYBRID_SEARCH.GET_SQL might return empty or invalid SQL + // if the feature is not supported, so we allow empty SQL to pass through + if (upperSql.length > 0 && !upperSql.startsWith("SELECT")) { throw new SeekdbValueError("Invalid SQL query: must start with SELECT"); } @@ -196,6 +204,10 @@ export class Collection { } } + if (idsArray.length === 0) { + throw new SeekdbValueError("ids cannot be empty"); + } + const { sql, params } = SQLBuilder.buildInsert(this.context, { ids: idsArray, documents: documentsArray ?? undefined, @@ -434,10 +446,27 @@ export class Collection { if (rows) { for (const row of rows) { - resultIds.push(row[CollectionFieldNames.ID].toString()); + if (!row[CollectionFieldNames.ID]) { + throw new Error( + `ID field '${CollectionFieldNames.ID}' not found in row. Available keys: ${Object.keys(row).join(", ")}` + ); + } + // Normalize values + const idValue = normalizeValue(row[CollectionFieldNames.ID]); + const idString = + idValue !== null && idValue !== undefined ? String(idValue) : null; + if (idString !== null) { + resultIds.push(idString); + } if (!include || include.includes("documents")) { - resultDocuments.push(row[CollectionFieldNames.DOCUMENT]); + const docValue = normalizeValue(row[CollectionFieldNames.DOCUMENT]); + // Preserve null for null document (match server; round-trip add({ documents: [null] }) -> get() -> null) + resultDocuments.push( + docValue !== null && docValue !== undefined + ? String(docValue) + : (null as any) + ); } if (!include || include.includes("metadatas")) { @@ -516,7 +545,7 @@ export class Collection { const allIds: string[][] = []; const allDocuments: (string | null)[][] = []; const allMetadatas: (TMeta | null)[][] = []; - const allEmbeddings: number[][][] = []; + const allEmbeddings: (number[] | null)[][] = []; const allDistances: number[][] = []; // Query for each vector @@ -540,15 +569,32 @@ export class Collection { const queryIds: string[] = []; const queryDocuments: (string | null)[] = []; const queryMetadatas: (TMeta | null)[] = []; - const queryEmbeddings: number[][] = []; + const queryEmbeddings: (number[] | null)[] = []; const queryDistances: number[] = []; - if (rows) { + if (rows && rows.length > 0) { for (const row of rows) { - queryIds.push(row[CollectionFieldNames.ID].toString()); + if (!row[CollectionFieldNames.ID]) { + // Row missing ID field, skip it + continue; + } + const idValue = row[CollectionFieldNames.ID]; + const idValueNormalized = normalizeValue(idValue); + const idString = + idValueNormalized !== null && idValueNormalized !== undefined + ? String(idValueNormalized) + : null; + if (idString !== null) { + queryIds.push(idString); + } if (!include || include.includes("documents")) { - queryDocuments.push(row[CollectionFieldNames.DOCUMENT] || null); + const docValue = normalizeValue(row[CollectionFieldNames.DOCUMENT]); + queryDocuments.push( + docValue !== null && docValue !== undefined + ? String(docValue) + : null + ); } if (!include || include.includes("metadatas")) { @@ -747,28 +793,67 @@ export class Collection { // Get SQL query from DBMS_HYBRID_SEARCH.GET_SQL const getSqlQuery = SQLBuilder.buildHybridSearchGetSql(tableName); - const getSqlResult = await this.#client.execute(getSqlQuery); + let getSqlResult; + try { + getSqlResult = await this.#client.execute(getSqlQuery); + } catch (error: any) { + // If DBMS_HYBRID_SEARCH is not supported, throw error so test can handle it + const errorMsg = error.message || ""; + if ( + errorMsg.includes("SQL syntax") || + errorMsg.includes("DBMS_HYBRID_SEARCH") || + errorMsg.includes("Unknown database function") || + errorMsg.includes("function") || + errorMsg.includes("syntax") + ) { + throw new SeekdbValueError( + `DBMS_HYBRID_SEARCH is not supported: ${errorMsg}` + ); + } + throw error; + } if ( !getSqlResult || getSqlResult.length === 0 || !getSqlResult[0].query_sql ) { - return { - ids: [[]], - distances: [[]], - metadatas: [[]], - documents: [[]], - embeddings: [[]], - }; + throw new SeekdbValueError( + "DBMS_HYBRID_SEARCH.GET_SQL returned no result" + ); } // Execute the returned SQL query with security validation - const querySql = getSqlResult[0].query_sql - .trim() - .replace(/^['"]|['"]$/g, ""); + let querySql = getSqlResult[0].query_sql; + + // Normalize querySql using generic normalization + let normalizedSql = normalizeValue(querySql); + + // Convert to string and clean up + if (typeof normalizedSql === "string") { + querySql = normalizedSql.trim().replace(/^['"]|['"]$/g, ""); + } else { + querySql = String(normalizedSql) + .trim() + .replace(/^['"]|['"]$/g, ""); + } // Security check: Validate the SQL query before execution + // If querySql is empty or invalid, it means DBMS_HYBRID_SEARCH is not supported + if (!querySql || querySql.length === 0) { + throw new SeekdbValueError( + "DBMS_HYBRID_SEARCH.GET_SQL returned empty SQL (feature not supported)" + ); + } + + // If SQL doesn't start with SELECT, it means DBMS_HYBRID_SEARCH is not supported + const upperSql = querySql.toUpperCase().trim(); + if (!upperSql.startsWith("SELECT")) { + throw new SeekdbValueError( + `DBMS_HYBRID_SEARCH.GET_SQL returned invalid SQL: ${querySql.substring(0, 100)} (feature not supported)` + ); + } + this.validateDynamicSql(querySql); const resultRows = await this.#client.execute(querySql); @@ -777,7 +862,7 @@ export class Collection { const ids: string[] = []; const documents: (string | null)[] = []; const metadatas: (TMeta | null)[] = []; - const embeddings: number[][] = []; + const embeddings: (number[] | null)[] = []; const distances: number[] = []; if (resultRows) { @@ -840,6 +925,11 @@ export class Collection { async fork(options: ForkOptions): Promise { const { name: targetName } = options; + if (!this.client) { + throw new SeekdbValueError( + "Collection fork requires a client reference; this collection was created without one." + ); + } if (await this.client.hasCollection(targetName)) { throw new SeekdbValueError( `Collection '${targetName}' already exists. Please use a different name.` diff --git a/packages/seekdb/src/connection.ts b/packages/seekdb/src/connection.ts index 874e256..9334e85 100644 --- a/packages/seekdb/src/connection.ts +++ b/packages/seekdb/src/connection.ts @@ -20,6 +20,8 @@ export interface ConnectionConfig { password: string; database?: string; charset: string; + /** Optional OceanBase/seekdb query timeout in milliseconds (e.g. 60000 = 60s). */ + queryTimeout?: number; } /** @@ -48,6 +50,14 @@ export class Connection { database: this.config.database, charset: this.config.charset, }); + if (this.config.queryTimeout != null) { + try { + const timeoutUs = this.config.queryTimeout * 1000; + await this.connection.query(`SET ob_query_timeout = ${timeoutUs}`); + } catch { + // Ignore if server does not support ob_query_timeout (e.g. plain MySQL) + } + } } catch (error) { throw new SeekdbConnectionError( `Failed to connect to ${this.config.host}:${this.config.port}`, diff --git a/packages/seekdb/src/embedding-function.ts b/packages/seekdb/src/embedding-function.ts index 54c2cd1..76aa6d9 100644 --- a/packages/seekdb/src/embedding-function.ts +++ b/packages/seekdb/src/embedding-function.ts @@ -5,6 +5,17 @@ const registry: Map = (globalThis as any)[REGISTRY_KEY] ?? ((globalThis as any)[REGISTRY_KEY] = new Map()); +/** + * Check if an embedding function is already registered. + * + * @experimental This API is experimental and may change in future versions. + * @param name - The name of the embedding function + * @returns true if the embedding function is registered, false otherwise + */ +export const isEmbeddingFunctionRegistered = (name: string): boolean => { + return registry.has(name); +}; + /** * Register a custom embedding function. * diff --git a/packages/seekdb/src/factory.ts b/packages/seekdb/src/factory.ts new file mode 100644 index 0000000..f2c0005 --- /dev/null +++ b/packages/seekdb/src/factory.ts @@ -0,0 +1,157 @@ +/** + * Factory functions for creating seekdb clients + * Automatically selects embedded or remote server mode based on parameters + */ + +import { SeekdbClient } from "./client.js"; +import { SeekdbAdminClient } from "./client-admin.js"; +import type { SeekdbClientArgs, SeekdbAdminClientArgs } from "./types.js"; +import { + DEFAULT_TENANT, + DEFAULT_DATABASE, + DEFAULT_PORT, + DEFAULT_USER, +} from "./utils.js"; +import * as path from "node:path"; + +/** + * Resolve password from environment variable if not provided + */ +function _resolvePassword(password?: string): string { + return password ?? process.env.SEEKDB_PASSWORD ?? ""; +} + +/** + * Get default seekdb path (current working directory) + */ +function _defaultSeekdbPath(): string { + return path.resolve(process.cwd(), "seekdb.db"); +} + +/** + * Create server client (embedded or remote) + * This is the single change point for client creation + */ +function _createServerClient( + args: SeekdbClientArgs, + isAdmin: boolean = false +): SeekdbClient { + const { path: dbPath, host, port, tenant, database, user, password } = args; + + // Embedded mode: if path is provided + if (dbPath !== undefined) { + return new SeekdbClient({ + path: dbPath, + database: database ?? DEFAULT_DATABASE, + }); + } + + // Remote server mode: if host is provided + if (host !== undefined) { + const finalPort = port ?? DEFAULT_PORT; + const finalUser = user ?? DEFAULT_USER; + const finalPassword = _resolvePassword(password); + const finalTenant = tenant ?? DEFAULT_TENANT; + + // For remote server mode, we need to ensure host is provided + return new SeekdbClient({ + host, + port: finalPort, + tenant: finalTenant, + database: database ?? DEFAULT_DATABASE, + user: finalUser, + password: finalPassword, + charset: args.charset, + }); + } + + // Default behavior: try embedded mode if available + // Note: This will throw an error if native addon is not available + const defaultPath = _defaultSeekdbPath(); + + try { + return new SeekdbClient({ + path: defaultPath, + database: database ?? DEFAULT_DATABASE, + }); + } catch (error) { + throw new Error( + "Default embedded mode is not available because native addon could not be loaded. " + + "Please provide host/port parameters to use RemoteServerClient, or provide path parameter for embedded mode." + ); + } +} + +/** + * Smart client factory function + * + * Automatically selects embedded or remote server mode based on parameters: + * - If path is provided, uses embedded mode + * - If host/port is provided, uses remote server mode + * - If neither path nor host is provided, defaults to embedded mode (if available) + * + * @param args - Client configuration arguments + * @returns SeekdbClient instance (supports both embedded and server modes) + * + * @example + * ```typescript + * // Embedded mode with no args (default path: cwd/seekdb.db, default database) + * const client = SeekdbClient(); + * + * // Embedded mode with explicit path + * const client = SeekdbClient({ path: "/path/to/seekdb", database: "db1" }); + * + * // Embedded mode (default path: current working directory) + * const client = SeekdbClient({ database: "db1" }); + * + * // Remote server mode + * const client = Client({ + * host: "localhost", + * port: 2881, + * tenant: "sys", + * database: "db1", + * user: "root", + * password: "pass" + * }); + * ``` + */ +export function Client(args: SeekdbClientArgs = {}): SeekdbClient { + return _createServerClient(args, false); +} + +/** + * Smart admin client factory function + * + * Always returns SeekdbClient (same entry type as Client()). Uses database "information_schema" + * for admin operations (createDatabase, listDatabases, getDatabase, deleteDatabase). + * + * - If path is provided, uses embedded mode + * - If host/port is provided, uses remote server mode + * + * @param args - Admin client configuration arguments + * @returns SeekdbClient instance (connected to information_schema for admin use) + * + * @example + * ```typescript + * // Embedded mode + * const admin = AdminClient({ path: "/path/to/seekdb" }); + * + * // Remote server mode + * const admin = AdminClient({ + * host: "localhost", + * port: 2881, + * tenant: "sys", + * user: "root", + * password: "pass" + * }); + * ``` + */ +export function AdminClient(args: SeekdbAdminClientArgs = {}): SeekdbClient { + // Embedded: admin database is built-in in SeekdbEmbeddedClient; no need to specify. + // Server: connect to information_schema for admin operations. + const clientArgs: SeekdbClientArgs = + args.host !== undefined + ? { ...args, database: "information_schema" } + : { ...args }; + return _createServerClient(clientArgs, true); +} diff --git a/packages/seekdb/src/index.ts b/packages/seekdb/src/index.ts index dcf2d43..c58618f 100644 --- a/packages/seekdb/src/index.ts +++ b/packages/seekdb/src/index.ts @@ -3,7 +3,7 @@ */ export { SeekdbClient } from "./client.js"; -export { SeekdbAdminClient } from "./admin-client.js"; +export { SeekdbAdminClient } from "./client-admin.js"; export { Collection } from "./collection.js"; export { Database, OBDatabase } from "./database.js"; export { @@ -11,6 +11,9 @@ export { getEmbeddingFunction, } from "./embedding-function.js"; +// Admin client factory (returns SeekdbClient for admin operations) +export { AdminClient } from "./factory.js"; + export * from "./errors.js"; export * from "./types.js"; export { diff --git a/packages/seekdb/src/internal-client-embedded.ts b/packages/seekdb/src/internal-client-embedded.ts new file mode 100644 index 0000000..e1b336c --- /dev/null +++ b/packages/seekdb/src/internal-client-embedded.ts @@ -0,0 +1,103 @@ +/** + * Internal client for embedded mode (same interface as InternalClient, uses native addon). + * Addon is loaded on first use; may trigger on-demand download via js-bindings. + */ +import type { RowDataPacket } from "mysql2/promise"; +import type { IInternalClient } from "./types.js"; +import type { Database, Connection } from "@seekdb/js-bindings"; +import type { NativeBindings } from "./native-addon-loader.js"; +import { getNativeAddon } from "./native-addon-loader.js"; + +const _dbCache = new Map(); + +export class InternalEmbeddedClient implements IInternalClient { + private readonly path: string; + private readonly database: string; + private _db: Database | null = null; + private _connection: Connection | null = null; + private _initialized = false; + private _addon: NativeBindings | null = null; + + constructor(args: { path: string; database: string }) { + this.path = args.path; + this.database = args.database; + } + + /** Ensure connection; loads addon on first use (may download via js-bindings). Reuses Database by path. */ + private async _ensureConnection(): Promise { + if (!this._addon) this._addon = await getNativeAddon(); + + if (!this._initialized) { + let db = _dbCache.get(this.path); + if (db === undefined) { + try { + db = this._addon.open(this.path); + _dbCache.set(this.path, db); + } catch (error: unknown) { + const err = error as { message?: string }; + if (!err.message?.includes("initialized twice")) { + throw error; + } + db = _dbCache.get(this.path); + } + } + this._db = db ?? null; + this._initialized = true; + } + + if (this._connection === null) { + if (!this._db) { + throw new Error("Database not initialized"); + } + this._connection = this._addon.connect(this._db, this.database, true); + // Auto-set session defaults so 100KB+ documents work without user config (align with server behavior). + try { + await this._addon.execute( + this._connection, + "SET SESSION ob_default_lob_inrow_threshold = 262144", + undefined + ); + await this._addon.execute( + this._connection, + "SET SESSION max_allowed_packet = 2097152", + undefined + ); + } catch { + // Ignore if backend does not support these (e.g. older version); 100KB may still work with table default. + } + } + + return this._connection; + } + + isConnected(): boolean { + return this._connection !== null && this._initialized; + } + + async execute( + sql: string, + params?: unknown[] + ): Promise { + const conn = await this._ensureConnection(); + const addon = this._addon!; + const result = await addon.execute(conn, sql, params); + + if (!result || !result.rows) { + return null; + } + + const columns = result.columns || []; + const rows: RowDataPacket[] = []; + for (const row of result.rows) { + const rowObj: RowDataPacket = {} as RowDataPacket; + for (let i = 0; i < columns.length && i < row.length; i++) + rowObj[columns[i]] = row[i]; + rows.push(rowObj); + } + return rows; + } + + async close(): Promise { + // No-op (embedded DB is process-local; close_sync would block event loop) + } +} diff --git a/packages/seekdb/src/internal-client.ts b/packages/seekdb/src/internal-client.ts index f972f12..620759f 100644 --- a/packages/seekdb/src/internal-client.ts +++ b/packages/seekdb/src/internal-client.ts @@ -9,13 +9,20 @@ import { DEFAULT_CHARSET, } from "./utils.js"; -export class InternalClient { +import type { IInternalClient } from "./types.js"; + +export class InternalClient implements IInternalClient { private readonly connectionManager: Connection; public readonly tenant: string; public readonly database: string; constructor(args: SeekdbClientArgs) { const host = args.host; + if (!host) { + throw new Error( + "InternalClient requires host parameter. For embedded mode, use SeekdbEmbeddedClient directly." + ); + } const port = args.port ?? DEFAULT_PORT; this.tenant = args.tenant ?? DEFAULT_TENANT; this.database = args.database ?? DEFAULT_DATABASE; @@ -32,6 +39,7 @@ export class InternalClient { password, database: this.database, charset, + queryTimeout: args.queryTimeout, }); } diff --git a/packages/seekdb/src/metadata-manager.ts b/packages/seekdb/src/metadata-manager.ts index f9afb36..408769d 100644 --- a/packages/seekdb/src/metadata-manager.ts +++ b/packages/seekdb/src/metadata-manager.ts @@ -3,8 +3,7 @@ * This table stores collection metadata for v2 collections */ -import type { InternalClient } from "./internal-client.js"; - +import type { IInternalClient } from "./types.js"; import type { EmbeddingConfig, CreateCollectionOptions } from "./types.js"; export type CollectionVersion = "v2"; @@ -26,11 +25,21 @@ export interface CollectionMetadata { export const METADATA_TABLE_NAME = "sdk_collections"; +/** + * Get column value from row case-insensitively (embedded/MySQL may return COLLECTION_ID etc.) + */ +function getColumn(row: Record, columnName: string): unknown { + const key = Object.keys(row).find( + (k) => k.toLowerCase() === columnName.toLowerCase() + ); + return key !== undefined ? row[key] : (row as any)[columnName]; +} + /** * Ensure metadata table exists, create if not */ export async function ensureMetadataTable( - client: InternalClient + client: IInternalClient ): Promise { const createTableSql = ` CREATE TABLE IF NOT EXISTS ${METADATA_TABLE_NAME} ( @@ -56,7 +65,7 @@ export async function ensureMetadataTable( * Insert collection metadata and return the generated collection_id */ export async function insertCollectionMetadata( - client: InternalClient, + client: IInternalClient, collectionName: string, settings: CollectionMetadata["settings"] ): Promise { @@ -73,7 +82,7 @@ export async function insertCollectionMetadata( const settingsJson = JSON.stringify({ ...settings, version: "v2" }); await client.execute(insertSql, [collectionName, settingsJson]); - // Query the collection_id of the just-inserted record + // Query the collection_id of the just-inserted record (retry for read-after-write visibility in embedded) const selectSql = ` SELECT collection_id FROM ${METADATA_TABLE_NAME} @@ -81,16 +90,49 @@ export async function insertCollectionMetadata( ORDER BY created_at DESC LIMIT 1 `; + const maxRetries = 3; + const retryDelayMs = 20; + let result: Record[] | null = null; + for (let i = 0; i < maxRetries; i++) { + result = (await client.execute(selectSql, [collectionName])) as + | Record[] + | null; + if (result && result.length > 0) break; + if (i < maxRetries - 1) { + await new Promise((r) => setTimeout(r, retryDelayMs)); + } + } - const result = await client.execute(selectSql, [collectionName]); - + // Fallback: SELECT last row by created_at (handles param binding or visibility issues) if (!result || result.length === 0) { + const fallbackSql = ` + SELECT collection_id, collection_name + FROM ${METADATA_TABLE_NAME} + ORDER BY created_at DESC + LIMIT 1 + `; + const fallback = (await client.execute(fallbackSql)) as + | Record[] + | null; + if (fallback && fallback.length > 0) { + const row = fallback[0]; + const name = getColumn(row, "collection_name"); + if (String(name) === collectionName) { + const id = getColumn(row, "collection_id"); + if (id != null && typeof id === "string") return id; + } + } throw new Error( "Failed to retrieve collection_id after inserting metadata" ); } - const collectionId = result[0].collection_id as string; + const collectionId = getColumn(result[0], "collection_id"); + if (collectionId == null || typeof collectionId !== "string") { + throw new Error( + "Failed to retrieve collection_id after inserting metadata" + ); + } return collectionId; } catch (error) { if (error instanceof TypeError) @@ -108,7 +150,7 @@ export async function insertCollectionMetadata( * Get collection metadata by collection name */ export async function getCollectionMetadata( - client: InternalClient, + client: IInternalClient, collectionName: string ): Promise { const selectSql = ` @@ -159,7 +201,7 @@ export async function getCollectionMetadata( * Get collection metadata by collection_id */ export async function getCollectionMetadataById( - client: InternalClient, + client: IInternalClient, collectionId: string ): Promise { const selectSql = ` @@ -210,7 +252,7 @@ export async function getCollectionMetadataById( * Delete collection metadata */ export async function deleteCollectionMetadata( - client: InternalClient, + client: IInternalClient, collectionName: string ): Promise { const deleteSql = ` @@ -238,7 +280,7 @@ export async function deleteCollectionMetadata( * List all collection metadata */ export async function listCollectionMetadata( - client: InternalClient + client: IInternalClient ): Promise { const selectSql = ` SELECT collection_id, collection_name, settings, created_at, updated_at @@ -289,7 +331,7 @@ export async function listCollectionMetadata( * Check if metadata table exists */ export async function metadataTableExists( - client: InternalClient + client: IInternalClient ): Promise { const sql = `SHOW TABLES LIKE '${METADATA_TABLE_NAME}'`; diff --git a/packages/seekdb/src/native-addon-loader.ts b/packages/seekdb/src/native-addon-loader.ts new file mode 100644 index 0000000..31ef61a --- /dev/null +++ b/packages/seekdb/src/native-addon-loader.ts @@ -0,0 +1,47 @@ +/** + * Lazy load native addon when embedded mode is first used. + * Delegates to @seekdb/js-bindings (sync load or getNativeBindingAsync for on-demand download). + */ +import type * as Bindings from "@seekdb/js-bindings"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +export type NativeBindings = typeof Bindings; + +type BindingsModule = + | NativeBindings + | { getNativeBindingAsync: () => Promise }; + +let _cached: NativeBindings | null = null; +let _loadPromise: Promise | null = null; + +function isBinding(m: BindingsModule): m is NativeBindings { + return typeof (m as NativeBindings).open === "function"; +} + +export async function getNativeAddon(): Promise { + if (_cached) return _cached; + if (_loadPromise) return _loadPromise; + + _loadPromise = (async () => { + const m = require("@seekdb/js-bindings") as BindingsModule; + if (isBinding(m)) { + _cached = m; + return m; + } + if (typeof m.getNativeBindingAsync === "function") { + _cached = await m.getNativeBindingAsync(); + return _cached; + } + throw new Error( + "SeekDB native bindings could not be loaded. Ensure @seekdb/js-bindings is installed and your platform is supported, or set SEEKDB_BINDINGS_BASE_URL for on-demand download." + ); + })(); + + return _loadPromise; +} + +export function getNativeAddonSync(): NativeBindings | null { + return _cached; +} diff --git a/packages/seekdb/src/types.ts b/packages/seekdb/src/types.ts index 64c0a59..61ab3ca 100644 --- a/packages/seekdb/src/types.ts +++ b/packages/seekdb/src/types.ts @@ -2,8 +2,8 @@ * Type definitions for seekdb SDK */ -import { SeekdbClient } from "./client.js"; -import type { InternalClient } from "./internal-client.js"; +import type { RowDataPacket } from "mysql2/promise"; +import type { SeekdbClient } from "./client.js"; // ==================== Basic Types ==================== @@ -91,6 +91,15 @@ export interface QueryResult { export type DistanceMetric = "l2" | "cosine" | "inner_product"; +/** + * Internal client interface - implemented by both InternalClient and InternalEmbeddedClient + */ +export interface IInternalClient { + isConnected(): boolean; + execute(sql: string, params?: unknown[]): Promise; + close(): Promise; +} + export interface CollectionContext { name: string; collectionId?: string; @@ -104,9 +113,9 @@ export interface CollectionConfig { distance: DistanceMetric; embeddingFunction?: EmbeddingFunction; metadata?: Metadata; - client: SeekdbClient; - internalClient: InternalClient; collectionId?: string; // v2 format collection ID + client?: SeekdbClient; + internalClient: IInternalClient; } export interface HNSWConfiguration { @@ -161,17 +170,21 @@ export type ConfigurationParam = HNSWConfiguration | Configuration; // ==================== Client Configuration ==================== export interface SeekdbClientArgs { - host: string; + path?: string; // For embedded mode + host?: string; // For remote server mode port?: number; tenant?: string; database?: string; user?: string; password?: string; charset?: string; + /** Optional OceanBase/seekdb query timeout in milliseconds. */ + queryTimeout?: number; } export interface SeekdbAdminClientArgs { - host: string; + path?: string; // For embedded mode + host?: string; // For remote server mode port?: number; tenant?: string; user?: string; diff --git a/packages/seekdb/src/utils.ts b/packages/seekdb/src/utils.ts index b926522..1d21c21 100644 --- a/packages/seekdb/src/utils.ts +++ b/packages/seekdb/src/utils.ts @@ -229,6 +229,503 @@ export class CollectionFieldNames { static readonly EMBEDDING = "embedding"; } +/** + * Normalize value from database result + * Handles various formats and converts them to standard JavaScript types + * This is used to normalize embedded mode's JSON string format to standard values + */ +export function normalizeValue(value: any): any { + if (value === null || value === undefined) { + return null; + } + + // If it's already a standard type (not a JSON string), return as-is + if (typeof value !== "string") { + // Handle object with type information (e.g., {VARCHAR: "value"}) + if (value && typeof value === "object" && !Array.isArray(value)) { + // Try to extract the actual value from type-wrapped objects + const extracted = + value.VARCHAR || + value.MEDIUMTEXT || + value.TEXT || + value.LONGTEXT || + value.varchar || + value.mediumtext || + value.text || + value.longtext; + if (extracted !== undefined && extracted !== null) { + return extracted; + } + // If no type key found, return the object as-is + return value; + } + return value; + } + + // Handle JSON-like string format: {"VARCHAR":"value", ...} or {"MEDIUMTEXT":"value", ...} + const trimmed = value.trim(); + if ( + trimmed.startsWith("{") && + (trimmed.includes("VARCHAR") || + trimmed.includes("MEDIUMTEXT") || + trimmed.includes("TEXT") || + trimmed.includes("LONGTEXT")) + ) { + try { + // Try to parse as JSON + const cleaned = value.replace(/[\x00-\x1F\x7F]/g, ""); + const parsed = JSON.parse(cleaned); + // Extract the actual value from type-wrapped JSON + const extracted = + parsed.VARCHAR || + parsed.MEDIUMTEXT || + parsed.TEXT || + parsed.LONGTEXT || + parsed.varchar || + parsed.mediumtext || + parsed.text || + parsed.longtext; + if (extracted !== undefined && extracted !== null) { + return extracted; + } + // If extraction failed, try regex fallback + const match = value.match( + /"(?:VARCHAR|MEDIUMTEXT|TEXT|LONGTEXT)"\s*:\s*"([^"]+)"/ + ); + if (match && match[1]) { + return match[1]; + } + // Last resort: return original value + return value; + } catch (e) { + // If JSON parse fails, try regex extraction + const match = value.match( + /"(?:VARCHAR|MEDIUMTEXT|TEXT|LONGTEXT)"\s*:\s*"([^"]+)"/ + ); + if (match && match[1]) { + return match[1]; + } + // If regex also fails, return original value + return value; + } + } + + // Return string as-is if not JSON format + return value; +} + +/** + * Parse embedding column from binary (float32 little-endian, 4 bytes per float). + * Used when DB returns VECTOR as Buffer/Uint8Array. + */ +export function parseEmbeddingBinary(buf: Uint8Array): number[] | null { + if (buf.length % 4 !== 0) return null; + const arr: number[] = []; + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + for (let i = 0; i < buf.length; i += 4) { + arr.push(view.getFloat32(i, true)); + } + return arr; +} + +/** + * Parse embedding from string (raw bytes: each char code = byte). + * Used when DB returns VECTOR as binary string. + */ +export function parseEmbeddingBinaryString(str: string): number[] | null { + if (typeof str !== "string" || str.length % 4 !== 0) return null; + const bytes = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i) & 0xff; + } + return parseEmbeddingBinary(bytes); +} + +/** + * Normalize a row of data from database result + * Applies normalizeValue to all values in the row + */ +export function normalizeRow(row: any): any { + if (!row || typeof row !== "object") { + return row; + } + + const normalized: any = {}; + for (const [key, value] of Object.entries(row)) { + normalized[key] = normalizeValue(value); + } + return normalized; +} + +/** + * Normalize an array of rows from database result + */ +export function normalizeRows(rows: any[]): any[] { + if (!Array.isArray(rows)) { + return rows; + } + return rows.map((row) => normalizeRow(row)); +} + +/** + * Extract column value from row by trying multiple column name formats + * This is a generic helper that works for both embedded and server modes + */ +export function extractColumnValue( + row: any, + possibleColumnNames: string[] +): any { + if (!row || typeof row !== "object") { + return undefined; + } + + // Try exact match first + for (const colName of possibleColumnNames) { + if (colName in row) { + return normalizeValue(row[colName]); + } + } + + // Try case-insensitive match + const rowKeys = Object.keys(row); + for (const colName of possibleColumnNames) { + const lowerColName = colName.toLowerCase(); + const matchedKey = rowKeys.find( + (key) => key.toLowerCase() === lowerColName + ); + if (matchedKey) { + return normalizeValue(row[matchedKey]); + } + } + + // Try to find by checking if any key contains the column name + for (const colName of possibleColumnNames) { + const matchedKey = rowKeys.find((key) => + key.toLowerCase().includes(colName.toLowerCase()) + ); + if (matchedKey) { + return normalizeValue(row[matchedKey]); + } + } + + return undefined; +} + +/** + * Extract string value from row by trying multiple column name formats + */ +export function extractStringValue( + row: any, + possibleColumnNames: string[] +): string | null { + const value = extractColumnValue(row, possibleColumnNames); + if (value === null || value === undefined) { + return null; + } + return String(value); +} + +/** + * Extract embedding field from schema rows + * Generic helper that works for both embedded and server modes + */ +export function extractEmbeddingField(schema: any[]): any | null { + if (!Array.isArray(schema) || schema.length === 0) { + return null; + } + + // Try to find by Field name matching CollectionFieldNames.EMBEDDING + let embeddingField = schema.find((row: any) => { + const fieldName = extractStringValue(row, ["Field", "field", "FIELD"]); + return fieldName === CollectionFieldNames.EMBEDDING; + }); + + // Fallback: try to find by Type containing VECTOR + if (!embeddingField) { + embeddingField = schema.find((row: any) => { + const typeValue = extractStringValue(row, ["Type", "type", "TYPE"]); + return typeValue && /VECTOR\(/i.test(typeValue); + }); + } + + // Another fallback: check all values for VECTOR type + if (!embeddingField) { + for (const row of schema) { + for (const value of Object.values(row)) { + const strValue = typeof value === "string" ? value : String(value); + if (/VECTOR\(/i.test(strValue)) { + return row; + } + } + } + } + + return embeddingField; +} + +/** + * Extract dimension from embedding field + */ +export function extractDimension(embeddingField: any): number | null { + if (!embeddingField) { + return null; + } + + // Try to get Type value + let typeValue = extractStringValue(embeddingField, ["Type", "type", "TYPE"]); + + // If not found, search all values + if (!typeValue || !/VECTOR\(/i.test(typeValue)) { + for (const value of Object.values(embeddingField)) { + const strValue = typeof value === "string" ? value : String(value); + if (/VECTOR\(/i.test(strValue)) { + typeValue = strValue; + break; + } + } + } + + if (!typeValue || !/VECTOR\(/i.test(typeValue)) { + return null; + } + + const match = typeValue.match(/VECTOR\((\d+)\)/i); + if (!match) { + return null; + } + + return parseInt(match[1], 10); +} + +/** + * Extract distance from CREATE TABLE statement + * Generic helper that works for both embedded and server modes + */ +export function extractDistance(createTableRow: any): string | null { + if (!createTableRow || typeof createTableRow !== "object") { + return null; + } + + // Strategy 1: Try to find CREATE TABLE statement first (most reliable) + // Check common column names for SHOW CREATE TABLE result + let createStmt: string | null = null; + + // Try standard column names + const possibleColumnNames = [ + "Create Table", + "Create table", + "CREATE TABLE", + "col_1", + "col_0", + ]; + for (const colName of possibleColumnNames) { + if (colName in createTableRow) { + const value = createTableRow[colName]; + if (value !== null && value !== undefined) { + const strValue = String(value); + if (strValue.length > 0 && /CREATE TABLE/i.test(strValue)) { + createStmt = strValue; + break; + } + } + } + } + + // Strategy 2: If not found by column name, search all values + if (!createStmt) { + for (const value of Object.values(createTableRow)) { + if (value !== null && value !== undefined) { + const strValue = String(value); + if (strValue.length > 0 && /CREATE TABLE/i.test(strValue)) { + createStmt = strValue; + break; + } + } + } + } + + // Strategy 3: If CREATE TABLE statement found, extract distance from it + if (createStmt) { + const normalized = createStmt.replace(/\s+/g, " ").replace(/\n/g, " "); + + // Try exact match first: distance=l2, distance=cosine, etc. + const exactMatch = normalized.match( + /distance\s*=\s*(l2|cosine|inner_product|ip)\b/i + ); + if (exactMatch && exactMatch[1]) { + return exactMatch[1].toLowerCase(); + } + + // Try permissive match: distance= followed by any non-whitespace, non-comma, non-paren sequence + const permissiveMatch = normalized.match(/distance\s*=\s*([^,\s\)]+)/i); + if (permissiveMatch && permissiveMatch[1]) { + const parsedDistance = permissiveMatch[1] + .toLowerCase() + .replace(/['"]/g, "") + .trim(); + if ( + parsedDistance === "l2" || + parsedDistance === "cosine" || + parsedDistance === "inner_product" || + parsedDistance === "ip" + ) { + return parsedDistance; + } + } + } + + // Strategy 4: Fallback - search all values for distance= pattern (in case CREATE TABLE not found) + for (const value of Object.values(createTableRow)) { + if (value !== null && value !== undefined) { + const strValue = String(value); + const normalized = strValue.replace(/\s+/g, " ").replace(/\n/g, " "); + + if (normalized.includes("distance")) { + const exactMatch = normalized.match( + /distance\s*=\s*(l2|cosine|inner_product|ip)\b/i + ); + if (exactMatch && exactMatch[1]) { + return exactMatch[1].toLowerCase(); + } + + const permissiveMatch = normalized.match(/distance\s*=\s*([^,\s\)]+)/i); + if (permissiveMatch && permissiveMatch[1]) { + const parsedDistance = permissiveMatch[1] + .toLowerCase() + .replace(/['"]/g, "") + .trim(); + if ( + parsedDistance === "l2" || + parsedDistance === "cosine" || + parsedDistance === "inner_product" || + parsedDistance === "ip" + ) { + return parsedDistance; + } + } + } + } + } + + return null; +} + +/** + * Common column names for SHOW TABLES result + * Used for extracting table names in listCollections + */ +export const TABLE_NAME_COLUMNS: string[] = [ + "Tables_in_database", + "Table", + "table", + "TABLE", + "Table_name", + "table_name", + "TABLE_NAME", +]; + +/** + * Shared core logic for listCollections + * Extracts table names from query results and filters by prefix + * + * @param result - Query result rows + * @param prefix - Table name prefix to filter (e.g., "c$v1$") + * @returns Array of table names matching the prefix + */ +export function extractTableNamesFromResult( + result: any[], + prefix: string +): string[] { + const tableNames: string[] = []; + const seenNames = new Set(); + + for (const row of result) { + // Extract table name using generic extraction + let tableName = extractStringValue(row, [...TABLE_NAME_COLUMNS]); + + // Handle information_schema format (TABLE_NAME column) + if (!tableName && (row as any).TABLE_NAME) { + tableName = (row as any).TABLE_NAME; + } + + // If not found, try to get first string value from row + if (!tableName) { + for (const value of Object.values(row)) { + if (value !== null && value !== undefined) { + const strValue = String(value).trim(); + if (strValue.length > 0) { + tableName = strValue; + break; + } + } + } + } + + // Remove backticks if present + if (tableName && typeof tableName === "string") { + tableName = tableName.replace(/^`|`$/g, ""); + + // Only process if table name starts with prefix and we haven't seen it before + if (tableName.startsWith(prefix) && !seenNames.has(tableName)) { + seenNames.add(tableName); + tableNames.push(tableName); + } + } + } + + return tableNames; +} + +/** + * Query table names using multiple strategies + * Tries SHOW TABLES LIKE, then SHOW TABLES, then information_schema (if supported) + * + * @param internalClient - Internal client for executing queries + * @param prefix - Table name prefix to filter (e.g., "c$v1$") + * @param tryInformationSchema - Whether to try information_schema fallback (default: true) + * @returns Query result rows, or null if no results + */ +export async function queryTableNames( + internalClient: { + execute(sql: string, params?: unknown[]): Promise; + }, + prefix: string, + tryInformationSchema: boolean = true +): Promise { + // Strategy 1: Try SHOW TABLES LIKE first (more efficient if supported) + let sql = `SHOW TABLES LIKE '${prefix}%'`; + let result = await internalClient.execute(sql); + + // Strategy 2: If no results, try SHOW TABLES to get all tables and filter manually + if (!result || result.length === 0) { + sql = `SHOW TABLES`; + result = await internalClient.execute(sql); + } + + // Strategy 3: Fallback to information_schema (if supported and enabled) + if ((!result || result.length === 0) && tryInformationSchema) { + try { + // Get current database name + const dbResult = await internalClient.execute("SELECT DATABASE()"); + if (dbResult && dbResult.length > 0) { + const dbName = + (dbResult[0] as any)["DATABASE()"] || Object.values(dbResult[0])[0]; + if (dbName) { + result = await internalClient.execute( + `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME LIKE ?`, + [dbName, `${prefix}%`] + ); + } + } + } catch (fallbackError) { + // If information_schema is not supported (e.g., embedded mode), silently ignore + // This is expected behavior for embedded mode + } + } + + return result && result.length > 0 ? result : null; +} + /** * Default constants */ @@ -236,6 +733,8 @@ export const DEFAULT_VECTOR_DIMENSION = 384; export const DEFAULT_DISTANCE_METRIC = "cosine"; export const DEFAULT_TENANT = "sys"; // seekdb Server default tenant export const DEFAULT_DATABASE = "test"; +/** Built-in database for admin operations (createDatabase, listDatabases, getDatabase, deleteDatabase). Used internally by embedded client. */ +export const ADMIN_DATABASE = "information_schema"; export const DEFAULT_PORT = 2881; export const DEFAULT_USER = "root"; export const DEFAULT_CHARSET = "utf8mb4"; diff --git a/packages/seekdb/tests/README.md b/packages/seekdb/tests/README.md new file mode 100644 index 0000000..cb0bc1f --- /dev/null +++ b/packages/seekdb/tests/README.md @@ -0,0 +1,71 @@ +# Test Layout + +## Directory structure + +Tests are grouped by feature. Server and Embedded modes share the same layout under their roots. + +``` +tests/ +├── unit/ # Unit tests (no database) +├── client/ # Client creation, factory, connection +├── collection/ # Collection operations +├── embedding/ # Embedding function +├── admin/ # Admin / database management +├── data/ # Data normalization, etc. +├── edge-cases/ # Edge cases and errors +├── examples/ # Examples +├── test-utils.ts # Shared test helpers (server mode) +└── embedded/ # Embedded-mode tests (same layout; requires native addon) + ├── client/ + ├── mode-consistency.test.ts # Embedded vs server behavior consistency + ├── collection/ + ├── embedding/ + ├── data/ + ├── edge-cases/ + ├── examples/ + └── test-utils.ts # Embedded-specific helpers (getTestDbDir, cleanupTestDb, etc.) +``` + +## Import paths + +### Server-mode tests (`tests/{category}/`) + +- From src: `from "../../src/..."` +- From test-utils: `from "../test-utils.js"` + +### Embedded-mode tests (`tests/embedded/{category}/`) + +- From src: `from "../../../src/..."` (or `../../../src` when in subdirs like `embedded/collection/`) +- From root test-utils (e.g. `generateCollectionName`, `MockEmbeddingFunction`): `from "../../test-utils.js"` +- From embedded test-utils (`getTestDbDir`, `cleanupTestDb`, `getEmbeddedTestConfig`): `from "../test-utils.js"` (when in `embedded/client/`, `embedded/collection/`, etc., `../test-utils.js` points to `embedded/test-utils.ts`) + +### Unit tests (`tests/unit/`) + +- From src: `from "../../src/..."` +- From errors: `from "../../src/errors.js"` + +## Running tests + +```bash +# All tests (from repo root) +pnpm test + +# From packages/seekdb +pnpm exec vitest run + +# Specific area +pnpm exec vitest run tests/collection/ + +# Embedded only (requires native addon) +pnpm exec vitest run tests/embedded/ + +# Unit tests only (fastest) +pnpm exec vitest run tests/unit/ +``` + +## Embedded mode + +- **Location**: `tests/embedded/` mirrors the server layout so the same scenarios can run without a server. +- **Config**: Use `getEmbeddedTestConfig(testFileName)` for `{ path, database }`; admin tests use `AdminClient({ path })`. +- **Cleanup**: Call `cleanupTestDb(testFileName)` in `beforeAll`; each file uses its own DB dir via `getTestDbDir(testFileName)` to avoid cross-test effects. +- **Coverage**: See `tests/embedded/COVERAGE_REPORT.md` for how server and embedded tests align and any differences. diff --git a/packages/seekdb/tests/admin-database-management.test.ts b/packages/seekdb/tests/admin/admin-database-management.test.ts similarity index 83% rename from packages/seekdb/tests/admin-database-management.test.ts rename to packages/seekdb/tests/admin/admin-database-management.test.ts index bd57128..c002bdd 100644 --- a/packages/seekdb/tests/admin-database-management.test.ts +++ b/packages/seekdb/tests/admin/admin-database-management.test.ts @@ -4,14 +4,14 @@ * Supports configuring connection parameters via environment variables */ import { describe, test, expect, beforeAll, afterAll, vi } from "vitest"; -import { SeekdbAdminClient } from "../src/admin-client.js"; -import { OBDatabase, Database } from "../src/database.js"; -import { DEFAULT_TENANT } from "../src/utils.js"; +import { SeekdbAdminClient } from "../../src/client-admin.js"; +import { OBDatabase, Database } from "../../src/database.js"; +import { DEFAULT_TENANT } from "../../src/utils.js"; import { TEST_CONFIG, TEST_CONFIG_OB, generateDatabaseName, -} from "./test-utils.js"; +} from "../test-utils.js"; describe("AdminClient Database Management", () => { let adminClient: SeekdbAdminClient; @@ -147,6 +147,72 @@ describe("AdminClient Database Management", () => { await adminClient.deleteDatabase(testDbName); }); + test("database operations use client tenant when different tenant specified", async () => { + const testDbName = generateDatabaseName("test_server_db"); + const differentTenant = "different_tenant"; + + // Mock console.warn to capture warnings + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + // Create database with different tenant (should use client tenant) + await adminClient.createDatabase(testDbName, differentTenant); + + // Verify warning was issued + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Specified tenant '${differentTenant}' differs from client tenant '${TEST_CONFIG.tenant}', using client tenant` + ) + ); + + // Verify database was created with client tenant + const db = await adminClient.getDatabase(testDbName, differentTenant); + expect(db.tenant).toBe(TEST_CONFIG.tenant); // Should use client tenant, not specified tenant + + // Verify warning was issued again for getDatabase + expect(warnSpy).toHaveBeenCalledTimes(2); + + // Cleanup + await adminClient.deleteDatabase(testDbName, differentTenant); + expect(warnSpy).toHaveBeenCalledTimes(3); // Warning for deleteDatabase too + } finally { + warnSpy.mockRestore(); + } + }); + + test("database operations use client tenant when DEFAULT_TENANT specified", async () => { + const testDbName = generateDatabaseName("test_server_db"); + + // Mock console.warn to verify no warning is issued for DEFAULT_TENANT + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + // Create database with DEFAULT_TENANT (should not warn if it matches client tenant) + await adminClient.createDatabase(testDbName, DEFAULT_TENANT); + + // Get database - should use client tenant + const db = await adminClient.getDatabase(testDbName); + expect(db.tenant).toBe(TEST_CONFIG.tenant); + + // Cleanup + await adminClient.deleteDatabase(testDbName); + } finally { + warnSpy.mockRestore(); + } + }); + + test("list databases returns databases with correct tenant", async () => { + const databases = await adminClient.listDatabases(); + + // Verify all databases have the correct tenant (Server mode) + for (const db of databases) { + expect(db.tenant).toBe(TEST_CONFIG.tenant); + expect(db.name).toBeDefined(); + expect(db.charset).toBeDefined(); + expect(db.collation).toBeDefined(); + } + }); + test("get database throws error for non-existent database", async () => { const nonExistentDbName = generateDatabaseName("non_existent_db"); diff --git a/packages/seekdb/tests/client-creation.test.ts b/packages/seekdb/tests/client/client-creation.test.ts similarity index 98% rename from packages/seekdb/tests/client-creation.test.ts rename to packages/seekdb/tests/client/client-creation.test.ts index 6489476..8ac160d 100644 --- a/packages/seekdb/tests/client-creation.test.ts +++ b/packages/seekdb/tests/client/client-creation.test.ts @@ -3,11 +3,11 @@ * Supports configuring connection parameters via environment variables */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; -import { EmbeddingFunction, HNSWConfiguration } from "../src/types.js"; -import { TEST_CONFIG, generateCollectionName } from "./test-utils.js"; -import { SQLBuilder } from "../src/sql-builder.js"; -import { registerEmbeddingFunction } from "../src/embedding-function.js"; +import { SeekdbClient } from "../../src/client.js"; +import { EmbeddingFunction, HNSWConfiguration } from "../../src/types.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; +import { SQLBuilder } from "../../src/sql-builder.js"; +import { registerEmbeddingFunction } from "../../src/embedding-function.js"; describe("Client Creation and Collection Management", () => { let client: SeekdbClient; diff --git a/packages/seekdb/tests/client/connection-management.test.ts b/packages/seekdb/tests/client/connection-management.test.ts new file mode 100644 index 0000000..d9009d3 --- /dev/null +++ b/packages/seekdb/tests/client/connection-management.test.ts @@ -0,0 +1,83 @@ +/** + * Connection management tests for Server mode + * Tests connection lifecycle, state management, and error handling for server mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG } from "../test-utils.js"; + +describe("Server Mode - Connection Management", () => { + describe("Connection Management", () => { + test("isConnected returns false before any operation", async () => { + const client = new SeekdbClient(TEST_CONFIG); + // Connection is lazy, so should be false initially + expect(client.isConnected()).toBe(false); + await client.close(); + }); + + test("isConnected returns true after operation", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + // Perform an operation to establish connection + try { + await client.listCollections(); + // After operation, connection should be established + expect(client.isConnected()).toBe(true); + } catch (error) { + // If server not available, skip this test + // Connection state may vary + } + + await client.close(); + }); + + test("close() closes the connection", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + try { + await client.listCollections(); + expect(client.isConnected()).toBe(true); + + await client.close(); + // After close, connection should be closed + expect(client.isConnected()).toBe(false); + } catch (error) { + // If server not available, just close + await client.close(); + } + }); + + test("operations work after close and reconnect", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + try { + // First operation + await client.listCollections(); + await client.close(); + + // Second operation should reconnect automatically + const collections = await client.listCollections(); + expect(Array.isArray(collections)).toBe(true); + + await client.close(); + } catch (error) { + // If server not available, just close + await client.close(); + } + }); + + test("multiple close() calls are safe", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + try { + await client.listCollections(); + await client.close(); + await client.close(); // Second close should be safe + await client.close(); // Third close should be safe + } catch (error) { + await client.close(); + } + }); + }); +}); diff --git a/packages/seekdb/tests/collection/batch-operations.test.ts b/packages/seekdb/tests/collection/batch-operations.test.ts new file mode 100644 index 0000000..cb82b25 --- /dev/null +++ b/packages/seekdb/tests/collection/batch-operations.test.ts @@ -0,0 +1,170 @@ +/** + * Batch operations tests for Server mode + * Tests operations with large datasets and batch processing for server mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; + +describe("Server Mode - Batch Operations", () => { + describe("Batch Operations", () => { + let client: SeekdbClient; + + beforeAll(async () => { + client = new SeekdbClient({ + ...TEST_CONFIG, + queryTimeout: 60000, + }); + }); + + afterAll(async () => { + await client.close(); + }); + + test("add large batch of items", async () => { + const collectionName = generateCollectionName("test_large_batch"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const batchSize = 100; + const ids = Array.from({ length: batchSize }, (_, i) => `id_${i}`); + const embeddings = Array.from({ length: batchSize }, (_, i) => [ + i * 0.1, + i * 0.2, + i * 0.3, + ]); + const documents = Array.from( + { length: batchSize }, + (_, i) => `Document ${i}` + ); + const metadatas = Array.from({ length: batchSize }, (_, i) => ({ + index: i, + batch: "large", + })); + + await collection.add({ + ids, + embeddings, + documents, + metadatas, + }); + + // Verify all items were added + const results = await collection.get({ ids: ids.slice(0, 10) }); + expect(results.ids.length).toBe(10); + + // Verify count + const count = await client.countCollection(); + // Should have at least our batch + expect(count).toBeGreaterThanOrEqual(1); + + await client.deleteCollection(collectionName); + }, 60000); + + test("get large batch of items", async () => { + const collectionName = generateCollectionName("test_large_get"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const batchSize = 50; + const ids = Array.from({ length: batchSize }, (_, i) => `id_${i}`); + const embeddings = Array.from({ length: batchSize }, (_, i) => [ + i * 0.1, + i * 0.2, + i * 0.3, + ]); + + await collection.add({ + ids, + embeddings, + }); + + // Get all items + const results = await collection.get({ ids }); + expect(results.ids.length).toBe(batchSize); + expect(results.embeddings).toBeDefined(); + expect(results.embeddings!.length).toBe(batchSize); + + await client.deleteCollection(collectionName); + }, 60000); + + test("query with large result set", async () => { + const collectionName = generateCollectionName("test_large_query"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const batchSize = 30; + const ids = Array.from({ length: batchSize }, (_, i) => `id_${i}`); + const embeddings = Array.from({ length: batchSize }, (_, i) => [ + i * 0.1, + i * 0.2, + i * 0.3, + ]); + + await collection.add({ + ids, + embeddings, + }); + + // Query for all results + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: batchSize, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeLessThanOrEqual(batchSize); + expect(results.distances).toBeDefined(); + expect(results.distances![0].length).toBeLessThanOrEqual(batchSize); + + await client.deleteCollection(collectionName); + }, 60000); + + test("delete large batch of items", async () => { + const collectionName = generateCollectionName("test_large_delete"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const batchSize = 40; + const ids = Array.from({ length: batchSize }, (_, i) => `id_${i}`); + const embeddings = Array.from({ length: batchSize }, (_, i) => [ + i * 0.1, + i * 0.2, + i * 0.3, + ]); + + await collection.add({ + ids, + embeddings, + }); + + // Delete half of them + const idsToDelete = ids.slice(0, batchSize / 2); + await collection.delete({ ids: idsToDelete }); + + // Verify deleted items are gone + const results = await collection.get({ ids: idsToDelete }); + expect(results.ids.length).toBe(0); + + // Verify remaining items still exist + const remainingIds = ids.slice(batchSize / 2); + const remainingResults = await collection.get({ ids: remainingIds }); + expect(remainingResults.ids.length).toBe(remainingIds.length); + + await client.deleteCollection(collectionName); + }, 60000); + }); +}); diff --git a/packages/seekdb/tests/collection-dml.test.ts b/packages/seekdb/tests/collection/collection-dml.test.ts similarity index 98% rename from packages/seekdb/tests/collection-dml.test.ts rename to packages/seekdb/tests/collection/collection-dml.test.ts index 2a678ae..33fa9cb 100644 --- a/packages/seekdb/tests/collection-dml.test.ts +++ b/packages/seekdb/tests/collection/collection-dml.test.ts @@ -3,10 +3,10 @@ * Supports configuring connection parameters via environment variables */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; -import { Collection } from "../src/collection.js"; -import { TEST_CONFIG, generateCollectionName } from "./test-utils.js"; -import { SeekdbValueError } from "../src/errors.js"; +import { SeekdbClient } from "../../src/client.js"; +import { Collection } from "../../src/collection.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; +import { SeekdbValueError } from "../../src/errors.js"; describe("Collection DML Operations", () => { let client: SeekdbClient; diff --git a/packages/seekdb/tests/collection-fork.test.ts b/packages/seekdb/tests/collection/collection-fork.test.ts similarity index 96% rename from packages/seekdb/tests/collection-fork.test.ts rename to packages/seekdb/tests/collection/collection-fork.test.ts index 080ba67..68694ba 100644 --- a/packages/seekdb/tests/collection-fork.test.ts +++ b/packages/seekdb/tests/collection/collection-fork.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; -import { Collection } from "../src/collection.js"; -import { TEST_CONFIG, generateCollectionName } from "./test-utils.js"; -import { SeekdbValueError } from "../src/errors.js"; +import { SeekdbClient } from "../../src/client.js"; +import { Collection } from "../../src/collection.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; +import { SeekdbValueError } from "../../src/errors.js"; describe("Collection Fork Operations", () => { let client: SeekdbClient; diff --git a/packages/seekdb/tests/collection-get.test.ts b/packages/seekdb/tests/collection/collection-get.test.ts similarity index 98% rename from packages/seekdb/tests/collection-get.test.ts rename to packages/seekdb/tests/collection/collection-get.test.ts index 72b333d..5759bbc 100644 --- a/packages/seekdb/tests/collection-get.test.ts +++ b/packages/seekdb/tests/collection/collection-get.test.ts @@ -3,9 +3,9 @@ * Supports configuring connection parameters via environment variables */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; -import { Collection } from "../src/collection.js"; -import { TEST_CONFIG, generateCollectionName } from "./test-utils.js"; +import { SeekdbClient } from "../../src/client.js"; +import { Collection } from "../../src/collection.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; describe("Collection Get Operations", () => { let client: SeekdbClient; diff --git a/packages/seekdb/tests/collection/collection-hybrid-search.test.ts b/packages/seekdb/tests/collection/collection-hybrid-search.test.ts new file mode 100644 index 0000000..e2de660 --- /dev/null +++ b/packages/seekdb/tests/collection/collection-hybrid-search.test.ts @@ -0,0 +1,460 @@ +/** + * Collection hybrid search tests - testing collection.hybridSearch() interface for Server mode + * Supports configuring connection parameters via environment variables + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { Collection } from "../../src/collection.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; + +/** + * Helper function to check if error is due to DBMS_HYBRID_SEARCH not being supported + */ +function isHybridSearchNotSupported(error: any): boolean { + const errorMsg = error.message || ""; + return ( + errorMsg.includes("SQL syntax") || + errorMsg.includes("DBMS_HYBRID_SEARCH") || + errorMsg.includes("Unknown database function") + ); +} + +/** + * Helper function to handle hybrid search test execution with graceful fallback + */ +async function runHybridSearchTest(testFn: () => Promise): Promise { + try { + await testFn(); + } catch (error: any) { + if (isHybridSearchNotSupported(error)) { + console.warn( + "Skipping test: DBMS_HYBRID_SEARCH not supported on this database version" + ); + return; + } + throw error; + } +} + +describe("Collection Hybrid Search Operations", () => { + let client: SeekdbClient; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("Server Mode Hybrid Search", () => { + let collection: Collection; + let collectionName: string; + + beforeAll(async () => { + collectionName = generateCollectionName("test_hybrid_search"); + collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // Insert test data + await collection.add({ + ids: ["id1", "id2", "id3", "id4", "id5", "id6", "id7", "id8"], + embeddings: [ + [1.0, 2.0, 3.0], + [2.0, 3.0, 4.0], + [1.1, 2.1, 3.1], + [2.1, 3.1, 4.1], + [1.2, 2.2, 3.2], + [1.3, 2.3, 3.3], + [2.2, 3.2, 4.2], + [1.4, 2.4, 3.4], + ], + documents: [ + "Machine learning is a subset of artificial intelligence", + "Python programming language is widely used in data science", + "Deep learning algorithms for neural networks", + "Data science with Python and machine learning", + "Introduction to artificial intelligence and neural networks", + "Advanced machine learning techniques and algorithms", + "Python tutorial for beginners in programming", + "Natural language processing with machine learning", + ], + metadatas: [ + { category: "AI", page: 1, score: 95, tag: "ml" }, + { category: "Programming", page: 2, score: 88, tag: "python" }, + { category: "AI", page: 3, score: 92, tag: "ml" }, + { category: "Data Science", page: 4, score: 90, tag: "python" }, + { category: "AI", page: 5, score: 85, tag: "neural" }, + { category: "AI", page: 6, score: 93, tag: "ml" }, + { category: "Programming", page: 7, score: 87, tag: "python" }, + { category: "AI", page: 8, score: 91, tag: "nlp" }, + ], + }); + + // Wait a bit for indexes to be ready + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("hybrid search with full-text search only", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine learning", + }, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.documents).toBeDefined(); + expect(results.metadatas).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + }); + }); + + test("hybrid search with vector search only", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + nResults: 5, + }, + nResults: 5, + include: ["documents", "metadatas", "embeddings"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + expect(results.distances).toBeDefined(); + // Verify distances are reasonable + const distances = results.distances![0]; + expect(distances.length).toBeGreaterThan(0); + for (const dist of distances) { + expect(dist).toBeGreaterThanOrEqual(0); + } + } + }); + }); + + test("hybrid search with both full-text and vector search", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine learning", + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + nResults: 10, + }, + rank: { + rrf: { + rankWindowSize: 60, + rankConstant: 60, + }, + }, + nResults: 5, + include: ["documents", "metadatas", "embeddings"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + }); + }); + + test("hybrid search with metadata filter", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + $and: [ + { category: { $eq: "AI" } }, + { page: { $gte: 1 } }, + { page: { $lte: 5 } }, + ], + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + $and: [{ category: { $eq: "AI" } }, { score: { $gte: 90 } }], + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + // Verify metadata filters are applied (only if results returned) + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata) { + expect(metadata.category).toBe("AI"); + } + } + } + }); + }); + + test("hybrid search with logical operators", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $and: [{ $contains: "machine" }, { $contains: "learning" }], + }, + where: { + $or: [{ tag: { $eq: "ml" } }, { tag: { $eq: "python" } }], + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + tag: { $in: ["ml", "python"] }, + }, + nResults: 10, + }, + rank: { rrf: {} }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + // Verify logical operators are applied (only if results returned) + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata && metadata.tag) { + expect(["ml", "python"]).toContain(metadata.tag); + } + } + } + }); + }); + + test("hybrid search with simplified equality in metadata filter", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + $and: [ + { category: "AI" }, + { page: { $gte: 1 } }, + { page: { $lte: 5 } }, + ], + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + $and: [{ category: "AI" }, { score: { $gte: 90 } }], + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + // Verify metadata filters are applied (only if results returned) + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata) { + expect(metadata.category).toBe("AI"); + } + } + } + }); + }); + + test("hybrid search with $ne (not equal) operator", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + category: { $ne: "Programming" }, + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + category: { $ne: "Programming" }, + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + // Verify no results have category="Programming" + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata) { + expect(metadata.category).not.toBe("Programming"); + } + } + } + }); + }); + + test("hybrid search with $lt (less than) operator", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + score: { $lt: 90 }, + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + score: { $lt: 90 }, + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + // Verify all results have score < 90 + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata && metadata.score !== undefined) { + expect(metadata.score).toBeLessThan(90); + } + } + } + }); + }); + + test("hybrid search with $gt (greater than) operator", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + score: { $gt: 90 }, + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + score: { $gt: 90 }, + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + // Verify all results have score > 90 + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata && metadata.score !== undefined) { + expect(metadata.score).toBeGreaterThan(90); + } + } + } + }); + }); + + test("hybrid search with $nin (not in) operator", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + tag: { $nin: ["ml", "python"] }, + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + tag: { $nin: ["ml", "python"] }, + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + // Verify no results have tag in ["ml", "python"] + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata && metadata.tag) { + expect(["ml", "python"]).not.toContain(metadata.tag); + } + } + } + }); + }); + }); +}); diff --git a/packages/seekdb/tests/collection-metadata-v2.test.ts b/packages/seekdb/tests/collection/collection-metadata-v2.test.ts similarity index 95% rename from packages/seekdb/tests/collection-metadata-v2.test.ts rename to packages/seekdb/tests/collection/collection-metadata-v2.test.ts index 453b161..b58e4aa 100644 --- a/packages/seekdb/tests/collection-metadata-v2.test.ts +++ b/packages/seekdb/tests/collection/collection-metadata-v2.test.ts @@ -3,19 +3,19 @@ * Supports configuring connection parameters via environment variables */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; +import { SeekdbClient } from "../../src/client.js"; import { TEST_CONFIG, generateCollectionName, MockEmbeddingFunction, -} from "./test-utils.js"; +} from "../test-utils.js"; import { getCollectionMetadata, metadataTableExists, -} from "../src/metadata-manager.js"; -import { registerEmbeddingFunction } from "../src/embedding-function.js"; -import { COLLECTION_V1_PREFIX } from "../src/utils.js"; -import { Configuration } from "../src/types.js"; +} from "../../src/metadata-manager.js"; +import { registerEmbeddingFunction } from "../../src/embedding-function.js"; +import { COLLECTION_V1_PREFIX } from "../../src/utils.js"; +import { Configuration } from "../../src/types.js"; // Register the mock embedding function try { @@ -66,13 +66,15 @@ describe("Collection Metadata V2", () => { expect(collection.collectionId).toHaveLength(32); // UUID without dashes // Verify metadata table exists - const tableExists = await metadataTableExists((client as any)._internal); + const tableExists = await metadataTableExists( + (client as any)._delegate._internal + ); expect(tableExists).toBe(true); }); test("should store collection metadata in metadata table", async () => { const metadata = await getCollectionMetadata( - (client as any)._internal, + (client as any)._delegate._internal, collectionName ); @@ -153,7 +155,7 @@ describe("Collection Metadata V2", () => { // Verify metadata is cleaned up const metadata = await getCollectionMetadata( - (client as any)._internal, + (client as any)._delegate._internal, collectionName ); expect(metadata).toBeNull(); @@ -211,7 +213,7 @@ describe("Collection Metadata V2", () => { ) ORGANIZATION = HEAP `; - await (client as any)._internal.execute(createV1TableSql); + await (client as any)._delegate._internal.execute(createV1TableSql); // Verify v1 collection can be retrieved const v1Collection = await client.getCollection({ @@ -337,7 +339,7 @@ describe("Collection Metadata V2", () => { // Verify no metadata entry for v1 collection const v1Metadata = await getCollectionMetadata( - (client as any)._internal, + (client as any)._delegate._internal, v1CollectionName ); expect(v1Metadata).toBeNull(); @@ -352,7 +354,7 @@ describe("Collection Metadata V2", () => { // Verify metadata is cleaned up const v2Metadata = await getCollectionMetadata( - (client as any)._internal, + (client as any)._delegate._internal, v2CollectionName ); expect(v2Metadata).toBeNull(); @@ -409,7 +411,7 @@ describe("Collection Metadata V2", () => { }); const metadata = await getCollectionMetadata( - (client as any)._internal, + (client as any)._delegate._internal, name ); @@ -433,7 +435,7 @@ describe("Collection Metadata V2", () => { }); const metadata = await getCollectionMetadata( - (client as any)._internal, + (client as any)._delegate._internal, name ); @@ -519,7 +521,7 @@ describe("Collection Metadata V2", () => { }); const metadata = await getCollectionMetadata( - (client as any)._internal, + (client as any)._delegate._internal, name ); diff --git a/packages/seekdb/tests/collection-name-validation.test.ts b/packages/seekdb/tests/collection/collection-name-validation.test.ts similarity index 98% rename from packages/seekdb/tests/collection-name-validation.test.ts rename to packages/seekdb/tests/collection/collection-name-validation.test.ts index b843fad..3c80bec 100644 --- a/packages/seekdb/tests/collection-name-validation.test.ts +++ b/packages/seekdb/tests/collection/collection-name-validation.test.ts @@ -2,10 +2,10 @@ * Unit tests for collection name validation */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { validateCollectionName } from "../src/utils.js"; -import { SeekdbValueError } from "../src/errors.js"; -import { SeekdbClient } from "../src/client.js"; -import { TEST_CONFIG } from "./test-utils.js"; +import { validateCollectionName } from "../../src/utils.js"; +import { SeekdbValueError } from "../../src/errors.js"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG } from "../test-utils.js"; describe("Collection Name Validation", () => { describe("Valid names", () => { diff --git a/packages/seekdb/tests/collection-query.test.ts b/packages/seekdb/tests/collection/collection-query.test.ts similarity index 98% rename from packages/seekdb/tests/collection-query.test.ts rename to packages/seekdb/tests/collection/collection-query.test.ts index 9106da8..bd20b25 100644 --- a/packages/seekdb/tests/collection-query.test.ts +++ b/packages/seekdb/tests/collection/collection-query.test.ts @@ -3,9 +3,9 @@ * Supports configuring connection parameters via environment variables */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; -import { Collection } from "../src/collection.js"; -import { TEST_CONFIG, generateCollectionName } from "./test-utils.js"; +import { SeekdbClient } from "../../src/client.js"; +import { Collection } from "../../src/collection.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; describe("Collection Query Operations", () => { let client: SeekdbClient; diff --git a/packages/seekdb/tests/collection/complex-queries.test.ts b/packages/seekdb/tests/collection/complex-queries.test.ts new file mode 100644 index 0000000..6270c01 --- /dev/null +++ b/packages/seekdb/tests/collection/complex-queries.test.ts @@ -0,0 +1,268 @@ +/** + * Complex query scenarios tests for Server mode + * Tests advanced query features, filters, and edge cases for server mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; + +describe("Server Mode - Complex Query Scenarios", () => { + describe("Complex Queries", () => { + let client: SeekdbClient; + let collectionName: string; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + collectionName = generateCollectionName("test_complex_queries"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + await client.close(); + }); + + describe("Complex Metadata Filters", () => { + test("query with nested metadata filter", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + metadatas: [ + { nested: { key: "value1" }, score: 90 }, + { nested: { key: "value2" }, score: 85 }, + { nested: { key: "value1" }, score: 95 }, + ], + }); + + // Query with nested filter + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + where: { "nested.key": { $eq: "value1" } }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + }); + + test("query with multiple conditions using $and", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_and_filter"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + metadatas: [ + { category: "A", score: 90 }, + { category: "B", score: 85 }, + { category: "A", score: 95 }, + ], + }); + + // Query with $and (implicit) + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + where: { category: { $eq: "A" }, score: { $gte: 90 } }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThanOrEqual(0); + + await client.deleteCollection(collection.name); + }); + + test("query with $in operator on array", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_in_filter"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + metadatas: [ + { tags: ["tag1", "tag2"] }, + { tags: ["tag2", "tag3"] }, + { tags: ["tag1", "tag3"] }, + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + where: { tags: { $in: ["tag1"] } }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + + await client.deleteCollection(collection.name); + }); + }); + + describe("Query with Different Distance Metrics", () => { + test("query results differ with different distance metrics", async () => { + const l2Collection = await client.createCollection({ + name: generateCollectionName("test_l2_query"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const cosineCollection = await client.createCollection({ + name: generateCollectionName("test_cosine_query"), + configuration: { dimension: 3, distance: "cosine" }, + embeddingFunction: null, + }); + + const testData = { + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + }; + + await l2Collection.add(testData); + await cosineCollection.add(testData); + + const queryVector = [[1, 0, 0]]; + + const l2Results = await l2Collection.query({ + queryEmbeddings: queryVector, + nResults: 3, + }); + + const cosineResults = await cosineCollection.query({ + queryEmbeddings: queryVector, + nResults: 3, + }); + + // Results should be different (or at least structure should be correct) + expect(l2Results.ids).toBeDefined(); + expect(cosineResults.ids).toBeDefined(); + expect(l2Results.distances).toBeDefined(); + expect(cosineResults.distances).toBeDefined(); + + await client.deleteCollection(l2Collection.name); + await client.deleteCollection(cosineCollection.name); + }); + }); + + describe("Query with Include Parameters", () => { + test("query with include only embeddings", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_include_emb"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test"], + metadatas: [{ key: "value" }], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 1, + include: ["embeddings"], + }); + + expect(results.embeddings).toBeDefined(); + expect(results.documents).toBeUndefined(); + expect(results.metadatas).toBeUndefined(); + + await client.deleteCollection(collection.name); + }); + + test("query with include documents and metadatas", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_include_doc_meta"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test"], + metadatas: [{ key: "value" }], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 1, + include: ["documents", "metadatas"], + }); + + expect(results.documents).toBeDefined(); + expect(results.metadatas).toBeDefined(); + expect(results.embeddings).toBeUndefined(); + + await client.deleteCollection(collection.name); + }); + }); + + describe("Query with Multiple Query Vectors", () => { + test("query with multiple query vectors returns multiple result sets", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_multi_query"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + }); + + const results = await collection.query({ + queryEmbeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + nResults: 2, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(2); // One result set per query vector + expect(results.ids[0].length).toBeGreaterThan(0); + expect(results.ids[1].length).toBeGreaterThan(0); + + await client.deleteCollection(collection.name); + }); + }); + }); +}); diff --git a/packages/seekdb/tests/collection/hybrid-search-enhanced.test.ts b/packages/seekdb/tests/collection/hybrid-search-enhanced.test.ts new file mode 100644 index 0000000..2efae34 --- /dev/null +++ b/packages/seekdb/tests/collection/hybrid-search-enhanced.test.ts @@ -0,0 +1,284 @@ +/** + * Enhanced hybrid search tests for Server mode + * Tests advanced hybrid search features, RRF (rank), and edge cases for server mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; + +describe("Server Mode - Enhanced Hybrid Search", () => { + describe("Hybrid Search Enhanced", () => { + let client: SeekdbClient; + let collectionName: string; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + collectionName = generateCollectionName("test_hybrid_enhanced"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + await client.close(); + }); + + describe("Hybrid Search with RRF (Reciprocal Rank Fusion)", () => { + test("hybrid search with rank parameter", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // Insert test data + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + documents: [ + "machine learning document", + "python programming tutorial", + "data science with python", + ], + }); + + // Test hybrid search with rank parameter + try { + const results = await collection.hybridSearch({ + queryTexts: "machine learning", + queryEmbeddings: [[1, 2, 3]], + nResults: 3, + rank: true, // Enable RRF + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + } catch (error: any) { + // If hybrid search not supported, skip this test + if (error.message?.includes("not supported")) { + // Test skipped - feature not available + return; + } + throw error; + } + }); + + test("hybrid search without rank parameter", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_no_rank"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + documents: ["test document 1", "test document 2"], + }); + + try { + const results = await collection.hybridSearch({ + queryTexts: "test", + queryEmbeddings: [[1, 2, 3]], + nResults: 2, + rank: false, // Disable RRF + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + }); + + describe("Hybrid Search Edge Cases", () => { + test("hybrid search with empty results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_results"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // Don't add any data + + try { + const results = await collection.hybridSearch({ + queryTexts: "test", + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + }); + expect(results.ids).toBeDefined(); + // One query => one result set; empty collection => zero results for that query + expect(results.ids.length).toBe(1); + expect(results.ids[0].length).toBe(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + + test("hybrid search with only text, no vector results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_text_only"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + }); + + try { + // Use queryTexts that doesn't match, but queryEmbeddings that does + const results = await collection.hybridSearch({ + queryTexts: "completely different text that won't match", + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + // Should still return results based on vector similarity + expect(results.ids.length).toBeGreaterThanOrEqual(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + + test("hybrid search with only vector, no text results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_vector_only"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + }); + + try { + // Use queryEmbeddings that doesn't match, but queryTexts that does + const results = await collection.hybridSearch({ + queryTexts: "test document", + queryEmbeddings: [[100, 200, 300]], // Very different vector + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + // Should still return results based on text search + expect(results.ids.length).toBeGreaterThanOrEqual(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + }); + + describe("Hybrid Search with Filters", () => { + test("hybrid search with metadata filter", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_hybrid_filter"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + documents: ["test document 1", "test document 2"], + metadatas: [{ category: "A" }, { category: "B" }], + }); + + try { + const results = await collection.hybridSearch({ + queryTexts: "test", + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + where: { category: { $eq: "A" } }, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + + test("hybrid search with whereDocument filter", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_hybrid_where_doc"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + documents: ["machine learning", "python programming"], + }); + + try { + const results = await collection.hybridSearch({ + queryTexts: "test", + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + whereDocument: { $contains: "machine" }, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + }); + }); +}); diff --git a/packages/seekdb/tests/collection/query-approximate.test.ts b/packages/seekdb/tests/collection/query-approximate.test.ts new file mode 100644 index 0000000..3e55b4a --- /dev/null +++ b/packages/seekdb/tests/collection/query-approximate.test.ts @@ -0,0 +1,114 @@ +/** + * Query approximate parameter tests for Server mode + * Tests the approximate parameter in query operations for server mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; + +describe("Server Mode - Query Approximate Parameter", () => { + describe("Approximate Query", () => { + let client: SeekdbClient; + let collectionName: string; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + collectionName = generateCollectionName("test_approximate"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + await client.close(); + }); + + test("query with approximate=true (default)", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + }); + + // Query with approximate=true (default) + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 3, + approximate: true, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + expect(results.distances).toBeDefined(); + }); + + test("query with approximate=false", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_approximate_false"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + }); + + // Query with approximate=false + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 3, + approximate: false, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + expect(results.distances).toBeDefined(); + + await client.deleteCollection(collection.name); + }); + + test("query without approximate parameter (defaults to true)", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_approximate_default"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + }); + + // Query without approximate parameter (should default to true) + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 2, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + + await client.deleteCollection(collection.name); + }); + }); +}); diff --git a/packages/seekdb/tests/data/data-normalization.test.ts b/packages/seekdb/tests/data/data-normalization.test.ts new file mode 100644 index 0000000..35178ad --- /dev/null +++ b/packages/seekdb/tests/data/data-normalization.test.ts @@ -0,0 +1,227 @@ +/** + * Data normalization scenario tests for Server mode + * Tests various data formats (VARCHAR wrapper, JSON strings, etc.) for server mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; + +describe("Server Mode - Data Normalization Scenarios", () => { + describe("Metadata Normalization", () => { + let client: SeekdbClient; + let collectionName: string; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + collectionName = generateCollectionName("test_metadata_norm"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + await client.close(); + }); + + test("handles simple metadata", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + metadatas: [{ key: "value", num: 123 }], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({ key: "value", num: 123 }); + }); + + test("handles nested metadata", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_nested_meta"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + metadatas: [{ nested: { key: "value" }, array: [1, 2, 3] }], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({ + nested: { key: "value" }, + array: [1, 2, 3], + }); + + await client.deleteCollection(collection.name); + }); + + test("handles null metadata", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_null_meta"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + metadatas: [null], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toBe(null); + + await client.deleteCollection(collection.name); + }); + + test("handles empty metadata object", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_meta"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + metadatas: [{}], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({}); + + await client.deleteCollection(collection.name); + }); + }); + + describe("Document Normalization", () => { + let client: SeekdbClient; + let collectionName: string; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + collectionName = generateCollectionName("test_doc_norm"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + await client.close(); + }); + + test("handles simple document", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe("test document"); + }); + + test("handles empty document", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_doc"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: [""], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(""); + + await client.deleteCollection(collection.name); + }); + + test("handles long document", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_long_doc"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const longDoc = "a".repeat(10000); + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: [longDoc], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(longDoc); + + await client.deleteCollection(collection.name); + }); + }); + + describe("Embedding Normalization", () => { + let client: SeekdbClient; + let collectionName: string; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + collectionName = generateCollectionName("test_emb_norm"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + await client.close(); + }); + + test("handles embedding array format", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1.1, 2.2, 3.3]], + }); + + const results = await collection.get({ + ids: ["id1"], + include: ["embeddings"], + }); + expect(results.embeddings).toBeDefined(); + expect(results.embeddings![0]).toEqual([1.1, 2.2, 3.3]); + }); + }); +}); diff --git a/packages/seekdb/tests/edge-cases/edge-cases-and-errors.test.ts b/packages/seekdb/tests/edge-cases/edge-cases-and-errors.test.ts new file mode 100644 index 0000000..29651c2 --- /dev/null +++ b/packages/seekdb/tests/edge-cases/edge-cases-and-errors.test.ts @@ -0,0 +1,367 @@ +/** + * Edge cases and error handling tests for Server mode + * Tests boundary conditions, error scenarios, and special cases for server mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; +import { SeekdbValueError, SeekdbNotFoundError } from "../../src/errors.js"; + +describe("Server Mode - Edge Cases and Error Handling", () => { + describe("Edge Cases", () => { + let client: SeekdbClient; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("Collection Management Edge Cases", () => { + test("createCollection with empty name should fail", async () => { + await expect(async () => { + await client.createCollection({ + name: "", + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + }).rejects.toThrow(); + }); + + test("getCollection with non-existent collection should throw", async () => { + const nonExistentName = generateCollectionName("non_existent"); + await expect(async () => { + await client.getCollection({ + name: nonExistentName, + embeddingFunction: null, + }); + }).rejects.toThrow(); + }); + + test("deleteCollection with non-existent collection should throw", async () => { + const nonExistentName = generateCollectionName("non_existent"); + await expect(async () => { + await client.deleteCollection(nonExistentName); + }).rejects.toThrow(); + }); + + test("hasCollection returns false for non-existent collection", async () => { + const nonExistentName = generateCollectionName("non_existent"); + const exists = await client.hasCollection(nonExistentName); + expect(exists).toBe(false); + }); + }); + + describe("Data Operations Edge Cases", () => { + let collectionName: string; + + beforeAll(async () => { + collectionName = generateCollectionName("test_edge_cases"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("add with empty IDs array should fail", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await expect(async () => { + await collection.add({ + ids: [], + embeddings: [[1, 2, 3]], + }); + }).rejects.toThrow(SeekdbValueError); + }); + + test("add with null document should work", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_null_doc"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id_null_doc"], + embeddings: [[1, 2, 3]], + documents: [null as any], + }); + + const results = await collection.get({ ids: ["id_null_doc"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(null); + + await client.deleteCollection(collection.name); + }); + + test("add with empty string document should work", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_doc"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id_empty_doc"], + embeddings: [[1, 2, 3]], + documents: [""], + }); + + const results = await collection.get({ ids: ["id_empty_doc"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(""); + + await client.deleteCollection(collection.name); + }); + + test("add with null metadata should work", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_null_meta"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id_null_meta"], + embeddings: [[1, 2, 3]], + metadatas: [null], + }); + + const results = await collection.get({ ids: ["id_null_meta"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toBe(null); + + await client.deleteCollection(collection.name); + }); + + test("add with empty metadata object should work", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_meta"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id_empty_meta"], + embeddings: [[1, 2, 3]], + metadatas: [{}], + }); + + const results = await collection.get({ ids: ["id_empty_meta"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({}); + + await client.deleteCollection(collection.name); + }); + + test("get with empty IDs array should return empty results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_ids"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const results = await collection.get({ ids: [] }); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(0); + + await client.deleteCollection(collection.name); + }); + + test("get with non-existent IDs should return empty results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_nonexistent_ids"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const results = await collection.get({ ids: ["non_existent_id"] }); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(0); + + await client.deleteCollection(collection.name); + }); + + test("query with nResults=0 should return empty results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_query_zero"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 0, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBe(0); + + await client.deleteCollection(collection.name); + }); + + test("query with nResults larger than collection size should return all", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_query_large_n"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 100, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeLessThanOrEqual(2); + + await client.deleteCollection(collection.name); + }); + }); + + describe("Special Characters and Encoding", () => { + test("handles Unicode characters in documents", async () => { + const collectionName = generateCollectionName("test_unicode"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const unicodeText = "测试 🚀 中文 日本語 한국어"; + await collection.add({ + ids: ["id_unicode"], + embeddings: [[1, 2, 3]], + documents: [unicodeText], + }); + + const results = await collection.get({ ids: ["id_unicode"] }); + expect(results.documents![0]).toBe(unicodeText); + + await client.deleteCollection(collectionName); + }); + + test("handles special characters in metadata", async () => { + const collectionName = generateCollectionName("test_special_chars"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const specialMetadata = { + "key with spaces": "value", + "key-with-dashes": "value", + key_with_underscores: "value", + "key.with.dots": "value", + "key:with:colons": "value", + 'key"with"quotes': "value", + "key'with'quotes": "value", + "key\nwith\nnewlines": "value", + }; + + await collection.add({ + ids: ["id_special"], + embeddings: [[1, 2, 3]], + metadatas: [specialMetadata], + }); + + const results = await collection.get({ ids: ["id_special"] }); + expect(results.metadatas![0]).toEqual(specialMetadata); + + await client.deleteCollection(collectionName); + }); + + test("handles very long document", async () => { + const collectionName = generateCollectionName("test_long_doc"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const longDoc = "a".repeat(100000); // 100KB document + await collection.add({ + ids: ["id_long"], + embeddings: [[1, 2, 3]], + documents: [longDoc], + }); + + const results = await collection.get({ ids: ["id_long"] }); + expect(results.documents![0]).toBe(longDoc); + expect(results.documents![0].length).toBe(100000); + + await client.deleteCollection(collectionName); + }); + }); + }); + + describe("Error Recovery and Resilience", () => { + let client: SeekdbClient; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + test("operations work after error", async () => { + const collectionName = generateCollectionName("test_recovery"); + + // Try invalid operation first + try { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await expect(async () => { + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2]], // Wrong dimension + }); + }).rejects.toThrow(); + + // After error, valid operation should still work + await collection.add({ + ids: ["id2"], + embeddings: [[1, 2, 3]], // Correct dimension + }); + + const results = await collection.get({ ids: ["id2"] }); + expect(results.ids.length).toBe(1); + + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore if server not available + } + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/client/absolute-path.test.ts b/packages/seekdb/tests/embedded/client/absolute-path.test.ts new file mode 100644 index 0000000..9b996f3 --- /dev/null +++ b/packages/seekdb/tests/embedded/client/absolute-path.test.ts @@ -0,0 +1,157 @@ +/** + * Embedded mode - Absolute path verification for both SeekdbClient and AdminClient. + * Verifies that .so / C ABI works correctly when path is absolute (path.resolve). + * Run: pnpm --filter seekdb exec vitest run tests/embedded/client/absolute-path.test.ts + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { AdminClient } from "../../../src/factory.js"; +import { + getEmbeddedTestConfigAbsolute, + cleanupTestDbAbsolute, +} from "../test-utils.js"; +import { + generateCollectionName, + generateDatabaseName, +} from "../../test-utils.js"; + +const TEST_FILE = "absolute-path.test.ts"; +const TEST_CONFIG = getEmbeddedTestConfigAbsolute(TEST_FILE); + +describe("Embedded Mode - Absolute Path (SeekdbClient & AdminClient)", () => { + beforeAll(async () => { + await cleanupTestDbAbsolute(TEST_FILE); + }); + + afterAll(async () => { + try { + await cleanupTestDbAbsolute(TEST_FILE); + } catch { + // ignore + } + }); + + test("path is absolute", () => { + const pathModule = require("node:path"); + expect(pathModule.isAbsolute(TEST_CONFIG.path)).toBe(true); + }); + + test("After createDatabase, SeekdbClient can use the new database", async () => { + const dbName = generateDatabaseName("test_use_after_create"); + const collName = generateCollectionName("coll_in_new_db"); + const admin = AdminClient({ path: TEST_CONFIG.path }); + await admin.createDatabase(dbName); + await admin.close(); + const client = new SeekdbClient({ + path: TEST_CONFIG.path, + database: dbName, + }); + await client.listCollections(); + expect(client.isConnected()).toBe(true); + await client.createCollection({ + name: collName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + const list = await client.listCollections(); + expect(list.length).toBe(1); + expect(list[0].name).toBe(collName); + await client.close(); + }); + + describe("Same path, multiple databases (absolute path)", () => { + const DB_A = "abs_multi_db_a"; + const DB_B = "abs_multi_db_b"; + + afterAll(async () => { + try { + const admin = AdminClient({ path: TEST_CONFIG.path }); + try { + await admin.deleteDatabase(DB_A); + } catch { + // ignore + } + try { + await admin.deleteDatabase(DB_B); + } catch { + // ignore + } + await admin.close(); + } catch { + // ignore + } + }); + + test("admin creates two databases on same path", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + await admin.createDatabase(DB_A); + await admin.createDatabase(DB_B); + const list = await admin.listDatabases(); + const names = list.map((d) => d.name); + expect(names).toContain(DB_A); + expect(names).toContain(DB_B); + await admin.close(); + }); + + test("client on db_a creates collection, client on db_b creates collection", async () => { + const clientA = new SeekdbClient({ + path: TEST_CONFIG.path, + database: DB_A, + }); + const clientB = new SeekdbClient({ + path: TEST_CONFIG.path, + database: DB_B, + }); + + const nameA = generateCollectionName("coll_a"); + const nameB = generateCollectionName("coll_b"); + + await clientA.createCollection({ + name: nameA, + configuration: { dimension: 3, distance: "cosine" }, + embeddingFunction: null, + }); + await clientB.createCollection({ + name: nameB, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const listA = await clientA.listCollections(); + const listB = await clientB.listCollections(); + + expect(listA.length).toBe(1); + expect(listA[0].name).toBe(nameA); + expect(listB.length).toBe(1); + expect(listB[0].name).toBe(nameB); + + await clientA.close(); + await clientB.close(); + }); + + test("collections are isolated per database on same path", async () => { + const clientA = new SeekdbClient({ + path: TEST_CONFIG.path, + database: DB_A, + }); + const clientB = new SeekdbClient({ + path: TEST_CONFIG.path, + database: DB_B, + }); + + const listA = await clientA.listCollections(); + const listB = await clientB.listCollections(); + + const namesA = listA.map((c) => c.name); + const namesB = listB.map((c) => c.name); + + expect(namesA.every((n) => n.startsWith("coll_a_"))).toBe(true); + expect(namesB.every((n) => n.startsWith("coll_b_"))).toBe(true); + expect(namesA.some((n) => namesB.includes(n))).toBe(false); + + await clientA.close(); + await clientB.close(); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/client/admin-database.test.ts b/packages/seekdb/tests/embedded/client/admin-database.test.ts new file mode 100644 index 0000000..d51d311 --- /dev/null +++ b/packages/seekdb/tests/embedded/client/admin-database.test.ts @@ -0,0 +1,349 @@ +/** + * Embedded mode - Admin database management (createDatabase, getDatabase, listDatabases, deleteDatabase). + * Explicit createDatabase only; connect does NOT auto-create. + * Also verifies same path, multiple databases (collections isolated per database). + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { AdminClient } from "../../../src/factory.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; +import { SeekdbValueError } from "../../../src/errors.js"; +import { Database } from "../../../src/database.js"; +import { + generateCollectionName, + generateDatabaseName, +} from "../../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("admin-database.test.ts"); + +describe("Embedded Mode - Admin Database Management", () => { + beforeAll(async () => { + await cleanupTestDb("admin-database.test.ts"); + }); + + afterAll(async () => { + try { + const admin = AdminClient({ path: TEST_CONFIG.path }); + await admin.close(); + await new Promise((r) => setTimeout(r, 100)); + } catch { + // ignore + } + }); + + test("AdminClient createDatabase creates a new database", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + await admin.createDatabase("admin_created_db_1"); + const db = await admin.getDatabase("admin_created_db_1"); + expect(db.name).toBe("admin_created_db_1"); + await admin.close(); + }); + + test("AdminClient listDatabases includes created database and information_schema", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const list = await admin.listDatabases(); + const names = list.map((d) => d.name); + expect(names).toContain("admin_created_db_1"); + expect(names).toContain("information_schema"); + await admin.close(); + }); + + test("AdminClient getDatabase throws for non-existent database", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + await expect(admin.getDatabase("nonexistent_db_xyz")).rejects.toThrow( + SeekdbValueError + ); + await admin.close(); + }); + + test("AdminClient deleteDatabase removes database", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + await admin.createDatabase("admin_to_delete_db"); + expect((await admin.listDatabases()).map((d) => d.name)).toContain( + "admin_to_delete_db" + ); + await admin.deleteDatabase("admin_to_delete_db"); + expect((await admin.listDatabases()).map((d) => d.name)).not.toContain( + "admin_to_delete_db" + ); + await expect(admin.getDatabase("admin_to_delete_db")).rejects.toThrow( + SeekdbValueError + ); + await admin.close(); + }); + + test("Client with non-existent database fails on first operation (no auto-create)", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + try { + await admin.deleteDatabase("test_new_db"); + } catch { + // ignore + } + await admin.close(); + const client = new SeekdbClient({ + path: TEST_CONFIG.path, + database: "test_new_db", + }); + await expect(client.listCollections()).rejects.toThrow(); + await client.close(); + }); + + test("After createDatabase, Client can use the new database", async () => { + const dbName = generateDatabaseName("test_use_after_create"); + const collName = generateCollectionName("coll_in_new_db"); + const admin = AdminClient({ path: TEST_CONFIG.path }); + await admin.createDatabase(dbName); + await admin.close(); + const client = new SeekdbClient({ + path: TEST_CONFIG.path, + database: dbName, + }); + await client.listCollections(); + expect(client.isConnected()).toBe(true); + await client.createCollection({ + name: collName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + const list = await client.listCollections(); + expect(list.length).toBe(1); + expect(list[0].name).toBe(collName); + await client.close(); + }); + + describe("Admin database API (align with server)", () => { + test("list databases with limit", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const limitedDbs = await admin.listDatabases(5); + expect(limitedDbs.length).toBeLessThanOrEqual(5); + expect(Array.isArray(limitedDbs)).toBe(true); + await admin.close(); + }); + + test("list databases with limit and offset", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const offsetDbs = await admin.listDatabases(2, 1); + expect(offsetDbs.length).toBeLessThanOrEqual(2); + expect(Array.isArray(offsetDbs)).toBe(true); + await admin.close(); + }); + + test("database object equals method works correctly", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const testDbName = generateDatabaseName("test_embed_db"); + await admin.createDatabase(testDbName); + const db1 = await admin.getDatabase(testDbName); + const db2 = await admin.getDatabase(testDbName); + expect(db1.equals(db2)).toBe(true); + await admin.deleteDatabase(testDbName); + await admin.close(); + }); + + test("database object toString method returns name", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const testDbName = generateDatabaseName("test_embed_db"); + await admin.createDatabase(testDbName); + const db = await admin.getDatabase(testDbName); + expect(db.toString()).toBe(testDbName); + await admin.deleteDatabase(testDbName); + await admin.close(); + }); + + test("list databases with zero limit returns empty array", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const emptyDbs = await admin.listDatabases(0); + expect(emptyDbs).toBeDefined(); + expect(Array.isArray(emptyDbs)).toBe(true); + expect(emptyDbs.length).toBe(0); + await admin.close(); + }); + + test("list databases with offset beyond available returns empty array", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const allDbs = await admin.listDatabases(); + const offsetDbs = await admin.listDatabases(10, allDbs.length + 100); + expect(offsetDbs).toBeDefined(); + expect(Array.isArray(offsetDbs)).toBe(true); + expect(offsetDbs.length).toBe(0); + await admin.close(); + }); + + test("database object properties are correctly set", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const testDbName = generateDatabaseName("test_embed_props"); + await admin.createDatabase(testDbName); + const db = await admin.getDatabase(testDbName); + expect(db.name).toBe(testDbName); + expect(db).toBeInstanceOf(Database); + expect(typeof db.charset).toBe("string"); + expect(typeof db.collation).toBe("string"); + await admin.deleteDatabase(testDbName); + await admin.close(); + }); + + test("create and delete multiple databases in sequence", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const dbNames = [ + generateDatabaseName("test_seq_1"), + generateDatabaseName("test_seq_2"), + generateDatabaseName("test_seq_3"), + ]; + for (const dbName of dbNames) { + await admin.createDatabase(dbName); + const db = await admin.getDatabase(dbName); + expect(db.name).toBe(dbName); + } + const databases = await admin.listDatabases(); + const names = databases.map((d) => d.name); + for (const dbName of dbNames) { + expect(names).toContain(dbName); + } + for (const dbName of dbNames) { + await admin.deleteDatabase(dbName); + } + const after = await admin.listDatabases(); + const afterNames = after.map((d) => d.name); + for (const dbName of dbNames) { + expect(afterNames).not.toContain(dbName); + } + await admin.close(); + }); + + test("database equals method returns false for different databases", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const name1 = generateDatabaseName("test_embed_a"); + const name2 = generateDatabaseName("test_embed_b"); + await admin.createDatabase(name1); + await admin.createDatabase(name2); + const db1 = await admin.getDatabase(name1); + const db2 = await admin.getDatabase(name2); + expect(db1.equals(db2)).toBe(false); + await admin.deleteDatabase(name1); + await admin.deleteDatabase(name2); + await admin.close(); + }); + + test("delete database for non-existent is idempotent (no throw)", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const nonExistent = generateDatabaseName("non_existent"); + await expect(admin.deleteDatabase(nonExistent)).resolves.toBeUndefined(); + await admin.close(); + }); + + test("create database twice is idempotent (no throw)", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + const testDbName = generateDatabaseName("test_dup"); + await admin.createDatabase(testDbName); + await expect(admin.createDatabase(testDbName)).resolves.toBeUndefined(); + await admin.deleteDatabase(testDbName); + await admin.close(); + }); + }); + + describe("Same path, multiple databases", () => { + const DB_A = "multi_db_a"; + const DB_B = "multi_db_b"; + + afterAll(async () => { + try { + const admin = AdminClient({ path: TEST_CONFIG.path }); + try { + await admin.deleteDatabase(DB_A); + } catch { + // ignore + } + try { + await admin.deleteDatabase(DB_B); + } catch { + // ignore + } + await admin.close(); + await new Promise((r) => setTimeout(r, 100)); + } catch { + // ignore + } + }); + + test("admin creates two databases on same path", async () => { + const admin = AdminClient({ path: TEST_CONFIG.path }); + await admin.createDatabase(DB_A); + await admin.createDatabase(DB_B); + const list = await admin.listDatabases(); + const names = list.map((d) => d.name); + expect(names).toContain(DB_A); + expect(names).toContain(DB_B); + expect(names).toContain("information_schema"); + await admin.close(); + }); + + test("client on db_a creates collection, client on db_b creates collection", async () => { + const clientA = new SeekdbClient({ + path: TEST_CONFIG.path, + database: DB_A, + }); + const clientB = new SeekdbClient({ + path: TEST_CONFIG.path, + database: DB_B, + }); + + const nameA = generateCollectionName("coll_a"); + const nameB = generateCollectionName("coll_b"); + + await clientA.createCollection({ + name: nameA, + configuration: { dimension: 3, distance: "cosine" }, + embeddingFunction: null, + }); + await clientB.createCollection({ + name: nameB, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const listA = await clientA.listCollections(); + const listB = await clientB.listCollections(); + + expect(listA.length).toBe(1); + expect(listA[0].name).toBe(nameA); + expect(listB.length).toBe(1); + expect(listB[0].name).toBe(nameB); + + await clientA.close(); + await clientB.close(); + }); + + test("collections are isolated per database on same path", async () => { + const clientA = new SeekdbClient({ + path: TEST_CONFIG.path, + database: DB_A, + }); + const clientB = new SeekdbClient({ + path: TEST_CONFIG.path, + database: DB_B, + }); + + const listA = await clientA.listCollections(); + const listB = await clientB.listCollections(); + + const namesA = listA.map((c) => c.name); + const namesB = listB.map((c) => c.name); + + expect(namesA.every((n) => n.startsWith("coll_a_"))).toBe(true); + expect(namesB.every((n) => n.startsWith("coll_b_"))).toBe(true); + expect(namesA.some((n) => namesB.includes(n))).toBe(false); + + await clientA.close(); + await clientB.close(); + }); + + test("default database (test) has no collections from db_a or db_b", async () => { + const clientDefault = new SeekdbClient(TEST_CONFIG); + const list = await clientDefault.listCollections(); + const names = list.map((c) => c.name); + expect(names.some((n) => n.startsWith("coll_a_"))).toBe(false); + expect(names.some((n) => n.startsWith("coll_b_"))).toBe(false); + await clientDefault.close(); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/client/client-creation.test.ts b/packages/seekdb/tests/embedded/client/client-creation.test.ts new file mode 100644 index 0000000..f9965a1 --- /dev/null +++ b/packages/seekdb/tests/embedded/client/client-creation.test.ts @@ -0,0 +1,257 @@ +/** + * Client creation and connection tests - testing connection and collection management for Embedded mode + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { HNSWConfiguration } from "../../../src/types.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("client-creation.test.ts"); + +describe("Embedded Mode - Client Creation and Collection Management", () => { + beforeAll(async () => { + await cleanupTestDb("client-creation.test.ts"); + }); + + afterAll(async () => { + await cleanupTestDb("client-creation.test.ts"); + }); + + describe("Client Creation", () => { + test("create embedded client with path", async () => { + const client = new SeekdbClient(TEST_CONFIG); + expect(client).toBeDefined(); + expect(client instanceof SeekdbClient).toBe(true); + expect(client.isConnected()).toBe(false); + await client.close(); + }); + + test("create embedded admin client (SeekdbClient uses built-in admin for admin ops)", async () => { + const admin = new SeekdbClient(TEST_CONFIG); + expect(admin).toBeDefined(); + expect(admin instanceof SeekdbClient).toBe(true); + await admin.close(); + }); + }); + + describe("Collection Management", () => { + let client: SeekdbClient; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + test("create_collection - create a new collection", async () => { + const testCollectionName = generateCollectionName("test_collection"); + const testDimension = 3; + + const config: HNSWConfiguration = { + dimension: testDimension, + distance: "cosine", + }; + + const collection = await client.createCollection({ + name: testCollectionName, + configuration: config, + embeddingFunction: null, + }); + + expect(collection).toBeDefined(); + expect(collection.name).toBe(testCollectionName); + expect(collection.dimension).toBe(testDimension); + + // Cleanup + await client.deleteCollection(testCollectionName); + }); + + test("get_collection - get the collection we just created", async () => { + const testCollectionName = generateCollectionName("test_collection"); + const testDimension = 3; + + const config: HNSWConfiguration = { + dimension: testDimension, + distance: "l2", + }; + + const created = await client.createCollection({ + name: testCollectionName, + configuration: config, + embeddingFunction: null, + }); + + const retrieved = await client.getCollection({ + name: testCollectionName, + embeddingFunction: null, + }); + + expect(retrieved).toBeDefined(); + expect(retrieved.name).toBe(testCollectionName); + expect(retrieved.dimension).toBe(testDimension); + expect(retrieved.distance).toBe("l2"); + + // Cleanup + await client.deleteCollection(testCollectionName); + }); + + test("list_collections - list all collections", async () => { + const collectionName1 = generateCollectionName("test_list_1"); + const collectionName2 = generateCollectionName("test_list_2"); + + await client.createCollection({ + name: collectionName1, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await client.createCollection({ + name: collectionName2, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const collections = await client.listCollections(); + expect(collections).toBeDefined(); + expect(Array.isArray(collections)).toBe(true); + expect(collections.length).toBeGreaterThanOrEqual(2); + + // Verify collections exist + const names = collections.map((c) => c.name); + expect(names).toContain(collectionName1); + expect(names).toContain(collectionName2); + + // Cleanup + await client.deleteCollection(collectionName1); + await client.deleteCollection(collectionName2); + }); + + test("has_collection - check if collection exists", async () => { + const collectionName = generateCollectionName("test_has"); + await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const exists = await client.hasCollection(collectionName); + expect(exists).toBe(true); + + // Cleanup + await client.deleteCollection(collectionName); + }); + + test("has_collection - returns false for non-existing collection", async () => { + const collectionName = generateCollectionName("test_not_has"); + const exists = await client.hasCollection(collectionName); + expect(exists).toBe(false); + }); + + test("delete_collection - delete a collection", async () => { + const collectionName = generateCollectionName("test_delete"); + await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await client.deleteCollection(collectionName); + + const exists = await client.hasCollection(collectionName); + expect(exists).toBe(false); + }); + + test("get_or_create_collection - creates if not exists", async () => { + const collectionName = generateCollectionName("test_get_or_create_new"); + const collection = await client.getOrCreateCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + expect(collection).toBeDefined(); + expect(collection.name).toBe(collectionName); + + // Cleanup + await client.deleteCollection(collectionName); + }); + + test("get_or_create_collection - gets if exists", async () => { + const collectionName = generateCollectionName( + "test_get_or_create_existing" + ); + const created = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const retrieved = await client.getOrCreateCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + expect(retrieved.name).toBe(collectionName); + expect(retrieved.dimension).toBe(created.dimension); + + // Cleanup + await client.deleteCollection(collectionName); + }); + + test("count_collection - count collections", async () => { + const initialCount = await client.countCollection(); + + const collectionName1 = generateCollectionName("test_count_1"); + const collectionName2 = generateCollectionName("test_count_2"); + + await client.createCollection({ + name: collectionName1, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const count1 = await client.countCollection(); + expect(count1).toBe(initialCount + 1); + + await client.createCollection({ + name: collectionName2, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const count2 = await client.countCollection(); + expect(count2).toBe(initialCount + 2); + + // Cleanup + await client.deleteCollection(collectionName1); + await client.deleteCollection(collectionName2); + }); + + test("create collection with different distance metrics", async () => { + const distances: Array<"l2" | "cosine" | "inner_product"> = [ + "l2", + "cosine", + "inner_product", + ]; + + for (const distance of distances) { + const collectionName = generateCollectionName(`test_${distance}`); + const collection = await client.createCollection({ + name: collectionName, + configuration: { + dimension: 3, + distance, + }, + embeddingFunction: null, + }); + + expect(collection.distance).toBe(distance); + await client.deleteCollection(collectionName); + } + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/client/connection-management.test.ts b/packages/seekdb/tests/embedded/client/connection-management.test.ts new file mode 100644 index 0000000..b8229ea --- /dev/null +++ b/packages/seekdb/tests/embedded/client/connection-management.test.ts @@ -0,0 +1,69 @@ +/** + * Connection management tests for Embedded mode + * Tests connection lifecycle, state management, and error handling for embedded mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("connection-management.test.ts"); + +describe("Embedded Mode - Connection Management", () => { + beforeAll(async () => { + await cleanupTestDb("connection-management.test.ts"); + }); + + test("isConnected returns false before any operation", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + // Connection is lazy, so should be false initially + expect(client.isConnected()).toBe(false); + await client.close(); + }); + + test("isConnected returns true after operation", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + // Perform an operation to establish connection + await client.listCollections(); + // After operation, connection should be established + expect(client.isConnected()).toBe(true); + + await client.close(); + }); + + test("close() is a no-op in embedded mode (no need to manually close)", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + await client.listCollections(); + expect(client.isConnected()).toBe(true); + + await client.close(); + // Embedded mode: close() is a no-op; connection state unchanged (unlike server mode) + expect(client.isConnected()).toBe(true); + }); + + test("operations work after close and reconnect", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + // First operation + await client.listCollections(); + await client.close(); + + // Second operation should reconnect automatically + const collections = await client.listCollections(); + expect(Array.isArray(collections)).toBe(true); + + await client.close(); + }); + + test("multiple close() calls are safe", async () => { + const client = new SeekdbClient(TEST_CONFIG); + + await client.listCollections(); + await client.close(); + await client.close(); // Second close should be safe + await client.close(); // Third close should be safe + }); +}); diff --git a/packages/seekdb/tests/embedded/client/factory-functions.test.ts b/packages/seekdb/tests/embedded/client/factory-functions.test.ts new file mode 100644 index 0000000..c26b67e --- /dev/null +++ b/packages/seekdb/tests/embedded/client/factory-functions.test.ts @@ -0,0 +1,153 @@ +/** + * Factory functions tests (Client/AdminClient) + * Lives under embedded/ because default path/host case requires native addon. + */ +import { describe, test, expect, beforeAll } from "vitest"; +import { Client, AdminClient } from "../../../src/factory.js"; +import { SeekdbClient } from "../../../src/client.js"; +import { getTestDbDir, cleanupTestDb } from "../test-utils.js"; + +const TEST_FILE = "factory-functions.test.ts"; +const TEST_DB_DIR = getTestDbDir(TEST_FILE); + +describe("Embedded Mode - Factory Functions", () => { + beforeAll(async () => { + await cleanupTestDb(TEST_FILE); + }); + + describe("Client() Factory Function", () => { + test("creates embedded client with path parameter", async () => { + const client = Client({ + path: TEST_DB_DIR, + database: "test", + }); + + expect(client).toBeDefined(); + expect(client instanceof SeekdbClient).toBe(true); + expect(client.isConnected()).toBe(false); + + await client.close(); + }); + + test("creates embedded client with default database", async () => { + const client = Client({ + path: TEST_DB_DIR, + }); + + expect(client).toBeDefined(); + expect(client instanceof SeekdbClient).toBe(true); + + await client.close(); + }); + + test("defaults to embedded mode when neither path nor host provided", async () => { + const client = Client({} as any); + expect(client).toBeDefined(); + expect(client instanceof SeekdbClient).toBe(true); + await client.close(); + }); + + test("creates server client with host parameter", async () => { + const client = Client({ + host: "127.0.0.1", + port: 2881, + user: "root", + password: "", + database: "test", + tenant: "sys", + }); + + expect(client).toBeDefined(); + expect(client instanceof SeekdbClient).toBe(true); + + try { + await client.close(); + } catch (error) { + // Ignore if server not available + } + }); + + test("creates server client with default values", async () => { + const client = Client({ + host: "127.0.0.1", + database: "test", + }); + + expect(client).toBeDefined(); + expect(client instanceof SeekdbClient).toBe(true); + + try { + await client.close(); + } catch (error) { + // Ignore if server not available + } + }); + }); + + describe("AdminClient() Factory Function", () => { + test("creates admin client with path parameter", async () => { + const admin = AdminClient({ + path: TEST_DB_DIR, + }); + + expect(admin).toBeDefined(); + expect(admin instanceof SeekdbClient).toBe(true); + + await admin.close(); + }); + + test("creates admin client with host parameter", async () => { + const admin = AdminClient({ + host: "127.0.0.1", + port: 2881, + user: "root", + password: "", + tenant: "sys", + }); + + expect(admin).toBeDefined(); + expect(admin instanceof SeekdbClient).toBe(true); + + try { + await admin.close(); + } catch (error) { + // Ignore if server not available + } + }); + }); + + describe("Factory Function Edge Cases", () => { + test("Client() with both path and host prefers path (embedded mode)", async () => { + const client = Client({ + path: TEST_DB_DIR, + host: "127.0.0.1", + database: "test", + }); + + expect(client).toBeDefined(); + expect(client instanceof SeekdbClient).toBe(true); + expect(client.isConnected()).toBe(false); + + await client.close(); + }); + + test("Client() with custom charset", async () => { + const client = Client({ + host: "127.0.0.1", + port: 2881, + user: "root", + password: "", + database: "test", + charset: "utf8mb4", + }); + + expect(client).toBeDefined(); + + try { + await client.close(); + } catch (error) { + // Ignore if server not available + } + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/batch-operations.test.ts b/packages/seekdb/tests/embedded/collection/batch-operations.test.ts new file mode 100644 index 0000000..169cf62 --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/batch-operations.test.ts @@ -0,0 +1,171 @@ +/** + * Batch operations tests for Embedded mode + * Tests operations with large datasets and batch processing for embedded mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("batch-operations.test.ts"); + +describe("Embedded Mode - Batch Operations", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("batch-operations.test.ts"); + client = new SeekdbClient({ + ...TEST_CONFIG, + queryTimeout: 60000, + }); + }); + + afterAll(async () => { + try { + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("Batch Operations", () => { + test("add large batch of items", async () => { + const collectionName = generateCollectionName("test_large_batch"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const batchSize = 100; + const ids = Array.from({ length: batchSize }, (_, i) => `id_${i}`); + const embeddings = Array.from({ length: batchSize }, (_, i) => [ + i * 0.1, + i * 0.2, + i * 0.3, + ]); + const documents = Array.from( + { length: batchSize }, + (_, i) => `Document ${i}` + ); + const metadatas = Array.from({ length: batchSize }, (_, i) => ({ + index: i, + batch: "large", + })); + + await collection.add({ + ids, + embeddings, + documents, + metadatas, + }); + + const results = await collection.get({ ids: ids.slice(0, 10) }); + expect(results.ids.length).toBe(10); + + const count = await client.countCollection(); + expect(count).toBeGreaterThanOrEqual(1); + + await client.deleteCollection(collectionName); + }); + + test("get large batch of items", async () => { + const collectionName = generateCollectionName("test_large_get"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const batchSize = 50; + const ids = Array.from({ length: batchSize }, (_, i) => `id_${i}`); + const embeddings = Array.from({ length: batchSize }, (_, i) => [ + i * 0.1, + i * 0.2, + i * 0.3, + ]); + + await collection.add({ + ids, + embeddings, + }); + + const results = await collection.get({ ids }); + expect(results.ids.length).toBe(batchSize); + expect(results.embeddings).toBeDefined(); + expect(results.embeddings!.length).toBe(batchSize); + + await client.deleteCollection(collectionName); + }); + + test("query with large result set", async () => { + const collectionName = generateCollectionName("test_large_query"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const batchSize = 30; + const ids = Array.from({ length: batchSize }, (_, i) => `id_${i}`); + const embeddings = Array.from({ length: batchSize }, (_, i) => [ + i * 0.1, + i * 0.2, + i * 0.3, + ]); + + await collection.add({ + ids, + embeddings, + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: batchSize, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeLessThanOrEqual(batchSize); + expect(results.distances).toBeDefined(); + expect(results.distances![0].length).toBeLessThanOrEqual(batchSize); + + await client.deleteCollection(collectionName); + }); + + test("delete large batch of items", async () => { + const collectionName = generateCollectionName("test_large_delete"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const batchSize = 40; + const ids = Array.from({ length: batchSize }, (_, i) => `id_${i}`); + const embeddings = Array.from({ length: batchSize }, (_, i) => [ + i * 0.1, + i * 0.2, + i * 0.3, + ]); + + await collection.add({ + ids, + embeddings, + }); + + const idsToDelete = ids.slice(0, batchSize / 2); + await collection.delete({ ids: idsToDelete }); + + const results = await collection.get({ ids: idsToDelete }); + expect(results.ids.length).toBe(0); + + const remainingIds = ids.slice(batchSize / 2); + const remainingResults = await collection.get({ ids: remainingIds }); + expect(remainingResults.ids.length).toBe(remainingIds.length); + + await client.deleteCollection(collectionName); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/collection-dml.test.ts b/packages/seekdb/tests/embedded/collection/collection-dml.test.ts new file mode 100644 index 0000000..29fe849 --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/collection-dml.test.ts @@ -0,0 +1,466 @@ +/** + * Collection DML tests - testing collection.add(), collection.delete(), collection.upsert(), collection.update() interfaces for Embedded mode + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { Collection } from "../../../src/collection.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { SeekdbValueError } from "../../../src/errors.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("collection-dml.test.ts"); + +describe("Embedded Mode - Collection DML Operations", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("collection-dml.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("Embedded Mode Collection DML", () => { + let collection: Collection; + let collectionName: string; + + beforeAll(async () => { + collectionName = generateCollectionName("test_dml"); + collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "cosine" }, + embeddingFunction: null, + }); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("collection.add - throws error for vector with NaN", async () => { + const testId = "test_id_nan"; + await expect(async () => { + await collection.add({ + ids: testId, + embeddings: [1.0, NaN, 3.0], + }); + }).rejects.toThrow(SeekdbValueError); + await expect(async () => { + await collection.add({ + ids: testId, + embeddings: [1.0, NaN, 3.0], + }); + }).rejects.toThrow("Vector contains invalid value: NaN"); + }); + + test("collection.add - throws error for vector with Infinity", async () => { + const testId = "test_id_inf"; + await expect(async () => { + await collection.add({ + ids: testId, + embeddings: [1.0, Infinity, 3.0], + }); + }).rejects.toThrow(SeekdbValueError); + await expect(async () => { + await collection.add({ + ids: testId, + embeddings: [1.0, Infinity, 3.0], + }); + }).rejects.toThrow("Vector contains invalid value: Infinity"); + }); + + test("collection.add - throws error for vector dimension mismatch at start", async () => { + const testId = "test_id_dim_mismatch_start"; + await expect(async () => { + await collection.add({ + ids: testId, + // Collection dimension is configured as 3, so providing 2 dims should fail + embeddings: [1.0, 2.0], + }); + }).rejects.toThrow(SeekdbValueError); + await expect(async () => { + await collection.add({ + ids: testId, + embeddings: [1.0, 2.0], + }); + }).rejects.toThrow("Dimension mismatch at index 0. Expected 3, got 2"); + }); + + test("collection.add - throws error for vector dimension mismatch in middle", async () => { + const testIds = ["id1", "id2", "id3"]; + await expect(async () => { + await collection.add({ + ids: testIds, + embeddings: [ + [1.0, 2.0, 3.0], // Correct + [1.0, 2.0], // Incorrect + [4.0, 5.0, 6.0], // Correct + ], + }); + }).rejects.toThrow(SeekdbValueError); + await expect(async () => { + await collection.add({ + ids: testIds, + embeddings: [ + [1.0, 2.0, 3.0], + [1.0, 2.0], + [4.0, 5.0, 6.0], + ], + }); + }).rejects.toThrow("Dimension mismatch at index 1. Expected 3, got 2"); + }); + + test("collection.update - throws error for vector with -Infinity", async () => { + const testId = "test_id_neg_inf"; + // First add a valid item + await collection.add({ + ids: testId, + embeddings: [1.0, 2.0, 3.0], + }); + + await expect(async () => { + await collection.update({ + ids: testId, + embeddings: [1.0, -Infinity, 3.0], + }); + }).rejects.toThrow(SeekdbValueError); + await expect(async () => { + await collection.update({ + ids: testId, + embeddings: [1.0, -Infinity, 3.0], + }); + }).rejects.toThrow("Vector contains invalid value: -Infinity"); + }); + + test("collection.add - add single item", async () => { + const testId1 = "test_id_1"; + await collection.add({ + ids: testId1, + embeddings: [1.0, 2.0, 3.0], + documents: "This is test document 1", + metadatas: { category: "test", score: 100 }, + }); + + // Verify using collection.get + const results = await collection.get({ ids: testId1 }); + expect(results.ids.length).toBe(1); + expect(results.ids[0]).toBe(testId1); + expect(results.documents![0]).toBe("This is test document 1"); + expect(results?.metadatas![0]?.category).toBe("test"); + }); + + test("collection.add - add multiple items", async () => { + const testIds = ["test_id_2", "test_id_3", "test_id_4"]; + await collection.add({ + ids: testIds, + embeddings: [ + [2.0, 3.0, 4.0], + [3.0, 4.0, 5.0], + [4.0, 5.0, 6.0], + ], + documents: ["Document 2", "Document 3", "Document 4"], + metadatas: [ + { category: "test", score: 90 }, + { category: "test", score: 85 }, + { category: "demo", score: 80 }, + ], + }); + + // Verify using collection.get + const results = await collection.get({ ids: testIds }); + expect(results.ids.length).toBe(3); + }); + + test("collection.update - update existing item", async () => { + const testId1 = "test_id_1"; + await collection.update({ + ids: testId1, + metadatas: { category: "test", score: 95, updated: true }, + }); + + // Verify update using collection.get + const results = await collection.get({ ids: testId1 }); + expect(results.ids.length).toBe(1); + expect(results.documents![0]).toBe("This is test document 1"); + expect(results?.metadatas![0]?.score).toBe(95); + expect(results?.metadatas![0]?.updated).toBe(true); + }); + + test("collection.update - update multiple items", async () => { + const testIds = ["test_id_2", "test_id_3"]; + await collection.update({ + ids: testIds, + embeddings: [ + [2.1, 3.1, 4.1], + [3.1, 4.1, 5.1], + ], + metadatas: [ + { category: "test", score: 92 }, + { category: "test", score: 87 }, + ], + }); + + // Verify update using collection.get + const results = await collection.get({ ids: testIds }); + expect(results.ids.length).toBe(2); + }); + + test("collection.upsert - upsert existing item (update)", async () => { + const testId1 = "test_id_1"; + await collection.upsert({ + ids: testId1, + embeddings: [1.0, 2.0, 3.0], + documents: "Upserted document 1", + metadatas: { category: "test", score: 98 }, + }); + + // Verify upsert using collection.get + const results = await collection.get({ ids: testId1 }); + expect(results.ids.length).toBe(1); + expect(results.documents![0]).toBe("Upserted document 1"); + expect(results?.metadatas![0]?.score).toBe(98); + }); + + test("collection.upsert - upsert new item (insert)", async () => { + const testIdNew = "test_id_new"; + await collection.upsert({ + ids: testIdNew, + embeddings: [5.0, 6.0, 7.0], + documents: "New upserted document", + metadatas: { category: "new", score: 99 }, + }); + + // Verify upsert using collection.get + const results = await collection.get({ ids: testIdNew }); + expect(results.ids.length).toBe(1); + expect(results.documents![0]).toBe("New upserted document"); + expect(results?.metadatas![0]?.category).toBe("new"); + }); + + test("collection.delete - delete by ID", async () => { + const testIds = ["test_id_2", "test_id_3", "test_id_4"]; + + await collection.delete({ ids: testIds[0] }); + + const results = await collection.get({ ids: testIds[0] }); + expect(results.ids.length).toBe(0); + + const otherResults = await collection.get({ + ids: [testIds[1], testIds[2]], + }); + expect(otherResults.ids.length).toBe(2); + }); + + test("collection.delete - delete by metadata filter", async () => { + await collection.delete({ where: { category: { $eq: "demo" } } }); + + const results = await collection.get({ + where: { category: { $eq: "demo" } }, + }); + expect(results.ids.length).toBe(0); + }); + + test("collection.delete - delete by document filter", async () => { + const testIdDoc = "test_id_doc"; + await collection.add({ + ids: testIdDoc, + embeddings: [6.0, 7.0, 8.0], + documents: "Delete this document", + metadatas: { category: "temp" }, + }); + + await collection.delete({ + whereDocument: { $contains: "Delete this" }, + }); + + const results = await collection.get({ + whereDocument: { $contains: "Delete this" }, + }); + expect(results.ids.length).toBe(0); + }); + + test("verify final state using collection.get", async () => { + const allResults = await collection.get({ limit: 100 }); + expect(allResults.ids.length).toBeGreaterThan(0); + }); + + test("collection.update - update only metadata without changing document", async () => { + const testId = "test_id_update_metadata_only"; + + await collection.add({ + ids: testId, + embeddings: [10.0, 11.0, 12.0], + documents: "Original document text", + metadatas: { status: "active", version: 1 }, + }); + + await collection.update({ + ids: testId, + metadatas: { status: "inactive", version: 2, updated: true }, + }); + + const results = await collection.get({ ids: testId }); + expect(results.ids.length).toBe(1); + expect(results.documents![0]).toBe("Original document text"); + expect(results?.metadatas![0]?.status).toBe("inactive"); + expect(results?.metadatas![0]?.version).toBe(2); + expect(results?.metadatas![0]?.updated).toBe(true); + }); + + test("collection.update - update only embeddings without changing document or metadata", async () => { + const testId = "test_id_update_embeddings_only"; + + await collection.add({ + ids: testId, + embeddings: [20.0, 21.0, 22.0], + documents: "Test document", + metadatas: { tag: "original" }, + }); + + await collection.update({ + ids: testId, + embeddings: [30.0, 31.0, 32.0], + }); + + const results = await collection.get({ ids: testId }); + expect(results.ids.length).toBe(1); + expect(results?.documents![0]).toBe("Test document"); + expect(results?.metadatas![0]?.tag).toBe("original"); + expect(results?.embeddings![0]).toEqual([30.0, 31.0, 32.0]); + }); + + test("collection.add - add item without document", async () => { + const testId = "test_id_no_document"; + + await collection.add({ + ids: testId, + embeddings: [40.0, 41.0, 42.0], + metadatas: { type: "vector_only" }, + }); + + const results = await collection.get({ ids: testId }); + expect(results.ids.length).toBe(1); + expect(results.ids[0]).toBe(testId); + expect(results?.metadatas![0]?.type).toBe("vector_only"); + }); + + test("collection.add - add item without metadata", async () => { + const testId = "test_id_no_metadata"; + + await collection.add({ + ids: testId, + embeddings: [50.0, 51.0, 52.0], + documents: "Document without metadata", + }); + + const results = await collection.get({ ids: testId }); + expect(results.ids.length).toBe(1); + expect(results?.documents![0]).toBe("Document without metadata"); + }); + + test("collection.delete - delete multiple IDs at once", async () => { + const testIds = ["test_id_multi_1", "test_id_multi_2", "test_id_multi_3"]; + + await collection.add({ + ids: testIds, + embeddings: [ + [60.0, 61.0, 62.0], + [61.0, 62.0, 63.0], + [62.0, 63.0, 64.0], + ], + documents: ["Doc 1", "Doc 2", "Doc 3"], + metadatas: [{ id: 1 }, { id: 2 }, { id: 3 }], + }); + + await collection.delete({ ids: [testIds[0], testIds[2]] }); + + const deletedResults = await collection.get({ + ids: [testIds[0], testIds[2]], + }); + expect(deletedResults.ids.length).toBe(0); + + const remainingResults = await collection.get({ ids: testIds[1] }); + expect(remainingResults.ids.length).toBe(1); + }); + + test("collection.delete - delete by combined metadata filters", async () => { + const testId = "test_id_combined_filter"; + + await collection.add({ + ids: testId, + embeddings: [70.0, 71.0, 72.0], + documents: "Test for combined filter", + metadatas: { category: "test", score: 100 }, + }); + + await collection.delete({ + where: { + category: { $eq: "test" }, + score: { $gte: 100 }, + }, + }); + + const results = await collection.get({ ids: testId }); + expect(results.ids.length).toBe(0); + }); + + test("collection.upsert - upsert multiple items", async () => { + const testIds = ["test_id_upsert_1", "test_id_upsert_2"]; + + await collection.upsert({ + ids: testIds, + embeddings: [ + [80.0, 81.0, 82.0], + [81.0, 82.0, 83.0], + ], + documents: ["Upsert doc 1", "Upsert doc 2"], + metadatas: [{ type: "upsert" }, { type: "upsert" }], + }); + + const results = await collection.get({ ids: testIds }); + expect(results.ids.length).toBe(2); + expect(results.documents![0]).toBe("Upsert doc 1"); + expect(results.documents![1]).toBe("Upsert doc 2"); + }); + + test("collection.add - throws error for duplicate ID", async () => { + const testId = "test_id_duplicate"; + + await collection.add({ + ids: testId, + embeddings: [90.0, 91.0, 92.0], + documents: "First document", + }); + + await expect(async () => { + await collection.add({ + ids: testId, + embeddings: [91.0, 92.0, 93.0], + documents: "Duplicate document", + }); + }).rejects.toThrow(); + }); + + test("collection.update - throws error for non-existent ID", async () => { + const nonExistentId = "test_id_nonexistent"; + + try { + await collection.update({ + ids: nonExistentId, + metadatas: { updated: true }, + }); + // Embedded may not throw; ensure no record was created + const results = await collection.get({ ids: nonExistentId }); + expect(results.ids.length).toBe(0); + } catch { + // Server mode throws for non-existent ID + } + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/collection-fork.test.ts b/packages/seekdb/tests/embedded/collection/collection-fork.test.ts new file mode 100644 index 0000000..1f2d8ab --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/collection-fork.test.ts @@ -0,0 +1,158 @@ +/** + * Embedded mode - Collection fork operations (same coverage as server collection-fork.test.ts) + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { Collection } from "../../../src/collection.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { SeekdbValueError } from "../../../src/errors.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("collection-fork.test.ts"); + +describe("Embedded Mode - Collection Fork Operations", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("collection-fork.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("Embedded Mode Collection Fork", () => { + let sourceCollection: Collection; + let sourceCollectionName: string; + let targetCollectionName: string; + + beforeAll(async () => { + sourceCollectionName = generateCollectionName("test_fork_source"); + sourceCollection = await client.createCollection({ + name: sourceCollectionName, + configuration: { dimension: 3, distance: "cosine" }, + embeddingFunction: null, + }); + await sourceCollection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + ], + metadatas: [ + { type: "A", value: 10 }, + { type: "B", value: 20 }, + { type: "A", value: 30 }, + ], + documents: ["doc1", "doc2", "doc3"], + }); + }); + + afterAll(async () => { + try { + await client.deleteCollection(sourceCollectionName); + if (targetCollectionName) { + await client.deleteCollection(targetCollectionName); + } + } catch { + // ignore + } + }); + + test("fork - create a valid fork and verify data", async () => { + targetCollectionName = generateCollectionName("test_fork_target"); + const targetCollection = await sourceCollection.fork({ + name: targetCollectionName, + }); + + expect(targetCollection).toBeDefined(); + expect(targetCollection.name).toBe(targetCollectionName); + + const result = await targetCollection.get(); + expect(result.ids.length).toBe(3); + expect(result.ids).toContain("id1"); + expect(result.ids).toContain("id2"); + expect(result.ids).toContain("id3"); + const id1Idx = result.ids.indexOf("id1"); + expect(result.embeddings![id1Idx]).toEqual([1.0, 2.0, 3.0]); + expect(result.metadatas![id1Idx]).toEqual({ type: "A", value: 10 }); + }); + + test("fork - verify isolation (source changes do not affect target)", async () => { + const tempTargetName = generateCollectionName("test_fork_isolation_1"); + const tempTarget = await sourceCollection.fork({ name: tempTargetName }); + + await sourceCollection.add({ + ids: "id_new_source", + embeddings: [10.0, 11.0, 12.0], + metadatas: { type: "new" }, + documents: "new source doc", + }); + + const sourceResult = await sourceCollection.get(); + expect(sourceResult.ids.length).toBe(4); + const targetResult = await tempTarget.get(); + expect(targetResult.ids.length).toBe(3); + expect(targetResult.ids).not.toContain("id_new_source"); + + try { + await client.deleteCollection(tempTargetName); + } catch { + // ignore + } + }); + + test("fork - verify isolation (target changes do not affect source)", async () => { + const sourceCountResult = await sourceCollection.get(); + const initialSourceCount = sourceCountResult.ids.length; + + const tempTargetName = generateCollectionName("test_fork_isolation_2"); + const tempTarget = await sourceCollection.fork({ name: tempTargetName }); + + await tempTarget.add({ + ids: "id_new_target", + embeddings: [20.0, 21.0, 22.0], + metadatas: { type: "new_target" }, + documents: "new target doc", + }); + + const targetResult = await tempTarget.get(); + expect(targetResult.ids.length).toBe(initialSourceCount + 1); + expect(targetResult.ids).toContain("id_new_target"); + const sourceResult = await sourceCollection.get(); + expect(sourceResult.ids.length).toBe(initialSourceCount); + expect(sourceResult.ids).not.toContain("id_new_target"); + + try { + await client.deleteCollection(tempTargetName); + } catch { + // ignore + } + }); + + test("fork - throws error if target collection already exists", async () => { + await expect(async () => { + await sourceCollection.fork({ name: sourceCollectionName }); + }).rejects.toThrow(SeekdbValueError); + + const existingName = generateCollectionName("existing_collection"); + await client.createCollection({ + name: existingName, + configuration: { dimension: 3 }, + embeddingFunction: null, + }); + + await expect(async () => { + await sourceCollection.fork({ name: existingName }); + }).rejects.toThrow(); + + try { + await client.deleteCollection(existingName); + } catch { + // ignore + } + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/collection-get.test.ts b/packages/seekdb/tests/embedded/collection/collection-get.test.ts new file mode 100644 index 0000000..a15a364 --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/collection-get.test.ts @@ -0,0 +1,372 @@ +/** + * Collection get tests - testing collection.get() interface for Embedded mode + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { Collection } from "../../../src/collection.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("collection-get.test.ts"); + +describe("Embedded Mode - Collection Get Operations", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("collection-get.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + try { + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore errors during cleanup + } + }); + + describe("Embedded Mode Collection Get", () => { + let collection: Collection; + let collectionName: string; + let insertedIds: string[]; + + beforeAll(async () => { + collectionName = generateCollectionName("test_get"); + collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + insertedIds = ["id1", "id2", "id3", "id4", "id5"]; + await collection.add({ + ids: insertedIds, + embeddings: [ + [1.0, 2.0, 3.0], + [2.0, 3.0, 4.0], + [1.1, 2.1, 3.1], + [2.1, 3.1, 4.1], + [1.2, 2.2, 3.2], + ], + documents: [ + "This is a test document about machine learning", + "Python programming tutorial for beginners", + "Advanced machine learning algorithms", + "Data science with Python", + "Introduction to neural networks", + ], + metadatas: [ + { category: "AI", score: 95, tag: "ml" }, + { category: "Programming", score: 88, tag: "python" }, + { category: "AI", score: 92, tag: "ml" }, + { category: "Data Science", score: 90, tag: "python" }, + { category: "AI", score: 85, tag: "neural" }, + ], + }); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("get by single ID", async () => { + const results = await collection.get({ ids: insertedIds[0] }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(1); + }); + + test("get by multiple IDs", async () => { + const results = await collection.get({ ids: insertedIds.slice(0, 2) }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(2); + }); + + test("get with metadata filter", async () => { + const results = await collection.get({ + where: { category: { $eq: "AI" } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + }); + + test("get with metadata filter using comparison operators", async () => { + const results = await collection.get({ + where: { score: { $gte: 90 } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + }); + + test("get with combined metadata filters", async () => { + const results = await collection.get({ + where: { category: { $eq: "AI" }, score: { $gte: 90 } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get with document filter", async () => { + const results = await collection.get({ + whereDocument: { $contains: "Python" }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get with $in operator", async () => { + const results = await collection.get({ + where: { tag: { $in: ["ml", "python"] } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get with limit and offset", async () => { + const results = await collection.get({ limit: 2, offset: 1 }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(2); + }); + + test("get with include parameter", async () => { + const results = await collection.get({ + ids: insertedIds.slice(0, 2), + include: ["documents", "metadatas", "embeddings"], + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.documents).toBeDefined(); + expect(results.metadatas).toBeDefined(); + expect(results.embeddings).toBeDefined(); + expect(results.ids.length).toBe(2); + }); + + test("get by multiple IDs returns dict format", async () => { + const results = await collection.get({ ids: insertedIds.slice(0, 3) }); + expect(results).toBeDefined(); + expect(typeof results).toBe("object"); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeLessThanOrEqual(3); + }); + + test("single ID returns dict format", async () => { + const results = await collection.get({ ids: insertedIds[0] }); + expect(results).toBeDefined(); + expect(typeof results).toBe("object"); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(1); + }); + + test("get with filters returns dict format", async () => { + const results = await collection.get({ + where: { category: { $eq: "AI" } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(typeof results).toBe("object"); + expect(results.ids).toBeDefined(); + }); + + test("get with logical operators ($or)", async () => { + const results = await collection.get({ + where: { + $or: [{ category: "AI" }, { tag: "python" }], + }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get with combined filters (where + whereDocument)", async () => { + const results = await collection.get({ + where: { category: { $eq: "AI" } }, + whereDocument: { $contains: "machine" }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get all data without filters", async () => { + const results = await collection.get({ limit: 100 }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + }); + + test("get with include parameter - only documents", async () => { + const results = await collection.get({ + ids: insertedIds.slice(0, 2), + include: ["documents"], + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.documents).toBeDefined(); + expect(results.metadatas).toBeUndefined(); + expect(results.embeddings).toBeUndefined(); + }); + + test("get with include parameter - only metadatas", async () => { + const results = await collection.get({ + ids: insertedIds.slice(0, 2), + include: ["metadatas"], + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.metadatas).toBeDefined(); + expect(results.documents).toBeUndefined(); + expect(results.embeddings).toBeUndefined(); + }); + + test("get with include parameter - only embeddings", async () => { + const results = await collection.get({ + ids: insertedIds.slice(0, 2), + include: ["embeddings"], + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.embeddings).toBeDefined(); + expect(results.documents).toBeUndefined(); + expect(results.metadatas).toBeUndefined(); + }); + + test("get with limit 0 returns empty results", async () => { + const results = await collection.get({ limit: 0 }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(0); + }); + + test("get with offset beyond available items returns empty results", async () => { + const allResults = await collection.get({ limit: 100 }); + const offsetResults = await collection.get({ + limit: 10, + offset: allResults.ids.length + 100, + }); + expect(offsetResults).toBeDefined(); + expect(offsetResults.ids.length).toBe(0); + }); + + test("get with $ne (not equal) operator", async () => { + const results = await collection.get({ + where: { category: { $ne: "AI" } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + if (results.metadatas) { + for (const metadata of results.metadatas) { + if (metadata) { + expect(metadata.category).not.toBe("AI"); + } + } + } + }); + + test("get with $lt (less than) operator", async () => { + const results = await collection.get({ + where: { score: { $lt: 90 } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + if (results.metadatas) { + for (const metadata of results.metadatas) { + if (metadata && metadata.score !== undefined) { + expect(metadata.score).toBeLessThan(90); + } + } + } + }); + + test("get with $lte (less than or equal) operator", async () => { + const results = await collection.get({ + where: { score: { $lte: 88 } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get with $gt (greater than) operator", async () => { + const results = await collection.get({ + where: { score: { $gt: 90 } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + if (results.metadatas) { + for (const metadata of results.metadatas) { + if (metadata && metadata.score !== undefined) { + expect(metadata.score).toBeGreaterThan(90); + } + } + } + }); + + test("get with $nin (not in) operator", async () => { + const results = await collection.get({ + where: { tag: { $nin: ["ml", "python"] } }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get with $and operator combining multiple conditions", async () => { + const results = await collection.get({ + where: { + $and: [ + { category: { $eq: "AI" } }, + { score: { $gte: 90 } }, + { tag: { $in: ["ml", "neural"] } }, + ], + }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get with document filter using $regex", async () => { + const results = await collection.get({ + whereDocument: { $regex: ".*[Pp]ython.*" }, + limit: 10, + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("get with empty IDs array - returns all records", async () => { + const results = await collection.get({ ids: [], limit: 100 }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(Array.isArray(results.ids)).toBe(true); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + }); + + test("get with non-existent IDs returns empty results", async () => { + const results = await collection.get({ + ids: ["non_existent_id_1", "non_existent_id_2"], + }); + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(0); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/collection-hybrid-search.test.ts b/packages/seekdb/tests/embedded/collection/collection-hybrid-search.test.ts new file mode 100644 index 0000000..b88ddbe --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/collection-hybrid-search.test.ts @@ -0,0 +1,454 @@ +/** + * Collection hybrid search tests - testing collection.hybridSearch() interface for Embedded mode + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { Collection } from "../../../src/collection.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("collection-hybrid-search.test.ts"); + +/** + * Helper function to check if error is due to DBMS_HYBRID_SEARCH not being supported + */ +function isHybridSearchNotSupported(error: any): boolean { + const errorMsg = error.message || ""; + return ( + errorMsg.includes("SQL syntax") || + errorMsg.includes("DBMS_HYBRID_SEARCH") || + errorMsg.includes("Unknown database function") + ); +} + +/** + * Helper function to handle hybrid search test execution with graceful fallback + */ +async function runHybridSearchTest(testFn: () => Promise): Promise { + try { + await testFn(); + } catch (error: any) { + if (isHybridSearchNotSupported(error)) { + console.warn( + "Skipping test: DBMS_HYBRID_SEARCH not supported on this database version" + ); + return; + } + throw error; + } +} + +describe("Embedded Mode - Collection Hybrid Search Operations", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("collection-hybrid-search.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("Embedded Mode Hybrid Search", () => { + let collection: Collection; + let collectionName: string; + + beforeAll(async () => { + collectionName = generateCollectionName("test_hybrid_search"); + collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // Insert test data (same as server mode) + await collection.add({ + ids: ["id1", "id2", "id3", "id4", "id5", "id6", "id7", "id8"], + embeddings: [ + [1.0, 2.0, 3.0], + [2.0, 3.0, 4.0], + [1.1, 2.1, 3.1], + [2.1, 3.1, 4.1], + [1.2, 2.2, 3.2], + [1.3, 2.3, 3.3], + [2.2, 3.2, 4.2], + [1.4, 2.4, 3.4], + ], + documents: [ + "Machine learning is a subset of artificial intelligence", + "Python programming language is widely used in data science", + "Deep learning algorithms for neural networks", + "Data science with Python and machine learning", + "Introduction to artificial intelligence and neural networks", + "Advanced machine learning techniques and algorithms", + "Python tutorial for beginners in programming", + "Natural language processing with machine learning", + ], + metadatas: [ + { category: "AI", page: 1, score: 95, tag: "ml" }, + { category: "Programming", page: 2, score: 88, tag: "python" }, + { category: "AI", page: 3, score: 92, tag: "ml" }, + { category: "Data Science", page: 4, score: 90, tag: "python" }, + { category: "AI", page: 5, score: 85, tag: "neural" }, + { category: "AI", page: 6, score: 93, tag: "ml" }, + { category: "Programming", page: 7, score: 87, tag: "python" }, + { category: "AI", page: 8, score: 91, tag: "nlp" }, + ], + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("hybrid search with full-text search only", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine learning", + }, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.documents).toBeDefined(); + expect(results.metadatas).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + }); + }); + + test("hybrid search with vector search only", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + nResults: 5, + }, + nResults: 5, + include: ["documents", "metadatas", "embeddings"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + expect(results.distances).toBeDefined(); + const distances = results.distances![0]; + expect(distances.length).toBeGreaterThan(0); + for (const dist of distances) { + expect(dist).toBeGreaterThanOrEqual(0); + } + } + }); + }); + + test("hybrid search with both full-text and vector search", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine learning", + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + nResults: 10, + }, + rank: { + rrf: { + rankWindowSize: 60, + rankConstant: 60, + }, + }, + nResults: 5, + include: ["documents", "metadatas", "embeddings"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + }); + }); + + test("hybrid search with metadata filter", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + $and: [ + { category: { $eq: "AI" } }, + { page: { $gte: 1 } }, + { page: { $lte: 5 } }, + ], + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + $and: [{ category: { $eq: "AI" } }, { score: { $gte: 90 } }], + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata) { + expect(metadata.category).toBe("AI"); + } + } + } + }); + }); + + test("hybrid search with logical operators", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $and: [{ $contains: "machine" }, { $contains: "learning" }], + }, + where: { + $or: [{ tag: { $eq: "ml" } }, { tag: { $eq: "python" } }], + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + tag: { $in: ["ml", "python"] }, + }, + nResults: 10, + }, + rank: { rrf: {} }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata && metadata.tag) { + expect(["ml", "python"]).toContain(metadata.tag); + } + } + } + }); + }); + + test("hybrid search with simplified equality in metadata filter", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + $and: [ + { category: "AI" }, + { page: { $gte: 1 } }, + { page: { $lte: 5 } }, + ], + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + $and: [{ category: "AI" }, { score: { $gte: 90 } }], + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata) { + expect(metadata.category).toBe("AI"); + } + } + } + }); + }); + + test("hybrid search with $ne (not equal) operator", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + category: { $ne: "Programming" }, + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + category: { $ne: "Programming" }, + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata) { + expect(metadata.category).not.toBe("Programming"); + } + } + } + }); + }); + + test("hybrid search with $lt (less than) operator", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + score: { $lt: 90 }, + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + score: { $lt: 90 }, + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata && metadata.score !== undefined) { + expect(metadata.score).toBeLessThan(90); + } + } + } + }); + }); + + test("hybrid search with $gt (greater than) operator", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + score: { $gt: 90 }, + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + score: { $gt: 90 }, + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata && metadata.score !== undefined) { + expect(metadata.score).toBeGreaterThan(90); + } + } + } + }); + }); + + test("hybrid search with $nin (not in) operator", async () => { + await runHybridSearchTest(async () => { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "machine", + }, + where: { + tag: { $nin: ["ml", "python"] }, + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [1.0, 2.0, 3.0], + where: { + tag: { $nin: ["ml", "python"] }, + }, + nResults: 10, + }, + nResults: 5, + include: ["documents", "metadatas"], + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + + if (results.ids.length > 0 && results.ids[0].length > 0) { + for (const metadata of results.metadatas![0]) { + if (metadata && metadata.tag) { + expect(["ml", "python"]).not.toContain(metadata.tag); + } + } + } + }); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/collection-metadata-v2.test.ts b/packages/seekdb/tests/embedded/collection/collection-metadata-v2.test.ts new file mode 100644 index 0000000..af99a3f --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/collection-metadata-v2.test.ts @@ -0,0 +1,495 @@ +/** + * Embedded mode - Collection Metadata V2 (same coverage as server collection-metadata-v2.test.ts) + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { + generateCollectionName, + MockEmbeddingFunction, +} from "../../test-utils.js"; +import { + getCollectionMetadata, + metadataTableExists, +} from "../../../src/metadata-manager.js"; +import { registerEmbeddingFunction } from "../../../src/embedding-function.js"; +import { COLLECTION_V1_PREFIX } from "../../../src/utils.js"; +import { Configuration } from "../../../src/types.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +try { + registerEmbeddingFunction("mock-embed", MockEmbeddingFunction as any); +} catch (e) { + // Ignore if already registered +} + +const TEST_CONFIG = getEmbeddedTestConfig("collection-metadata-v2.test.ts"); + +describe("Embedded Mode - Collection Metadata V2", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("collection-metadata-v2.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("V2 Collection Creation", () => { + let collectionName: string; + + beforeAll(async () => { + collectionName = generateCollectionName("test_v2"); + }); + + afterAll(async () => { + try { + const exists = await client.hasCollection(collectionName); + if (exists) { + await client.deleteCollection(collectionName); + } + } catch (error) { + console.error(`Failed to cleanup collection ${collectionName}:`, error); + } + }); + + test("should create metadata table on first collection creation", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { hnsw: { dimension: 3, distance: "cosine" } }, + embeddingFunction: null, + }); + + expect(collection).toBeDefined(); + expect(collection.name).toBe(collectionName); + expect(collection.collectionId).toBeDefined(); + expect(collection.collectionId).toHaveLength(32); + + const tableExists = await metadataTableExists( + (client as any)._delegate._internal + ); + expect(tableExists).toBe(true); + }); + + test("should store collection metadata in metadata table", async () => { + const metadata = await getCollectionMetadata( + (client as any)._delegate._internal, + collectionName + ); + + expect(metadata).toBeDefined(); + expect(metadata?.collectionName).toBe(collectionName); + expect(metadata?.collectionId).toBeDefined(); + expect( + (metadata?.settings.configuration as Configuration)?.hnsw?.dimension + ).toBe(3); + expect( + (metadata?.settings.configuration as Configuration)?.hnsw?.distance + ).toBe("cosine"); + }); + + test("should retrieve v2 collection with collectionId", async () => { + const collection = await client.getCollection({ name: collectionName }); + + expect(collection).toBeDefined(); + expect(collection.name).toBe(collectionName); + expect(collection.collectionId).toBeDefined(); + expect(collection.dimension).toBe(3); + expect(collection.distance).toBe("cosine"); + }); + + test("should list v2 collection", async () => { + const collections = await client.listCollections(); + + const found = collections.find((c) => c.name === collectionName); + expect(found).toBeDefined(); + expect(found?.collectionId).toBeDefined(); + }); + + test("should check v2 collection exists", async () => { + const exists = await client.hasCollection(collectionName); + expect(exists).toBe(true); + }); + + test("should perform CRUD operations on v2 collection", async () => { + const collection = await client.getCollection({ name: collectionName }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + ], + documents: ["doc1", "doc2"], + }); + + const result = await collection.get({ + ids: ["id1", "id2"], + include: ["documents", "embeddings"], + }); + + expect(result.ids).toHaveLength(2); + expect(result.documents).toBeDefined(); + expect(result.embeddings).toBeDefined(); + + const count = await collection.count(); + expect(count).toBe(2); + + await collection.delete({ ids: ["id1"] }); + + const countAfterDelete = await collection.count(); + expect(countAfterDelete).toBe(1); + }); + + test("should delete v2 collection and clean up metadata", async () => { + await client.deleteCollection(collectionName); + + const exists = await client.hasCollection(collectionName); + expect(exists).toBe(false); + + const metadata = await getCollectionMetadata( + (client as any)._delegate._internal, + collectionName + ); + expect(metadata).toBeNull(); + }); + }); + + describe("V1 and V2 Compatibility", () => { + let v1CollectionName: string; + let v2CollectionName: string; + + beforeAll(async () => { + v1CollectionName = generateCollectionName("test_v1_compat"); + v2CollectionName = generateCollectionName("test_v2_compat"); + }); + + afterAll(async () => { + try { + if (await client.hasCollection(v1CollectionName)) { + await client.deleteCollection(v1CollectionName); + } + } catch (error) { + console.error( + `Failed to cleanup v1 collection ${v1CollectionName}:`, + error + ); + } + try { + if (await client.hasCollection(v2CollectionName)) { + await client.deleteCollection(v2CollectionName); + } + } catch (error) { + console.error( + `Failed to cleanup v2 collection ${v2CollectionName}:`, + error + ); + } + }); + + test("should create v1 format collection (without metadata table)", async () => { + const v1TableName = `${COLLECTION_V1_PREFIX}${v1CollectionName}`; + const createV1TableSql = ` + CREATE TABLE \`${v1TableName}\` ( + _id VARBINARY(512) PRIMARY KEY NOT NULL, + document STRING, + embedding VECTOR(3), + metadata JSON, + FULLTEXT INDEX idx_fts (document) WITH PARSER ik, + VECTOR INDEX idx_vec (embedding) WITH(distance=cosine, type=hnsw, lib=vsag) + ) ORGANIZATION = HEAP + `; + + await (client as any)._delegate._internal.execute(createV1TableSql); + + const v1Collection = await client.getCollection({ + name: v1CollectionName, + }); + + expect(v1Collection).toBeDefined(); + expect(v1Collection.name).toBe(v1CollectionName); + expect(v1Collection.collectionId).toBeUndefined(); + expect(v1Collection.dimension).toBe(3); + expect(v1Collection.distance).toBe("cosine"); + }); + + test("should list both v1 and v2 collections", async () => { + await client.createCollection({ + name: v2CollectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const collections = await client.listCollections(); + + const v1Collection = collections.find((c) => c.name === v1CollectionName); + const v2Collection = collections.find((c) => c.name === v2CollectionName); + + expect(v1Collection).toBeDefined(); + expect(v1Collection?.collectionId).toBeUndefined(); + + expect(v2Collection).toBeDefined(); + expect(v2Collection?.collectionId).toBeDefined(); + }); + + test("should perform CRUD operations on v1 collection", async () => { + const collection = await client.getCollection({ + name: v1CollectionName, + }); + + await collection.add({ + ids: ["v1_id1", "v1_id2"], + embeddings: [ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + ], + documents: ["v1 doc1", "v1 doc2"], + }); + + const result = await collection.get({ + ids: ["v1_id1", "v1_id2"], + include: ["documents", "embeddings"], + }); + + expect(result.ids).toHaveLength(2); + expect(result.documents).toEqual(["v1 doc1", "v1 doc2"]); + + const count = await collection.count(); + expect(count).toBe(2); + + await collection.delete({ ids: ["v1_id1"] }); + + const countAfterDelete = await collection.count(); + expect(countAfterDelete).toBe(1); + }); + + test("should perform CRUD operations on v2 collection", async () => { + const collection = await client.getCollection({ + name: v2CollectionName, + }); + + await collection.add({ + ids: ["v2_id1", "v2_id2"], + embeddings: [ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + ], + documents: ["v2 doc1", "v2 doc2"], + }); + + const result = await collection.get({ + ids: ["v2_id1", "v2_id2"], + include: ["documents", "embeddings"], + }); + + expect(result.ids).toHaveLength(2); + expect(result.documents).toEqual(["v2 doc1", "v2 doc2"]); + + const count = await collection.count(); + expect(count).toBe(2); + + await collection.delete({ ids: ["v2_id1"] }); + + const countAfterDelete = await collection.count(); + expect(countAfterDelete).toBe(1); + }); + + test("should check existence of both v1 and v2 collections", async () => { + const v1Exists = await client.hasCollection(v1CollectionName); + const v2Exists = await client.hasCollection(v2CollectionName); + + expect(v1Exists).toBe(true); + expect(v2Exists).toBe(true); + }); + + test("should delete v1 collection without affecting metadata table", async () => { + await client.deleteCollection(v1CollectionName); + + const v1Exists = await client.hasCollection(v1CollectionName); + expect(v1Exists).toBe(false); + + const v2Exists = await client.hasCollection(v2CollectionName); + expect(v2Exists).toBe(true); + + const v1Metadata = await getCollectionMetadata( + (client as any)._delegate._internal, + v1CollectionName + ); + expect(v1Metadata).toBeNull(); + }); + + test("should delete v2 collection and clean up metadata", async () => { + await client.deleteCollection(v2CollectionName); + + const v2Exists = await client.hasCollection(v2CollectionName); + expect(v2Exists).toBe(false); + + const v2Metadata = await getCollectionMetadata( + (client as any)._delegate._internal, + v2CollectionName + ); + expect(v2Metadata).toBeNull(); + }); + + test("should support collection names up to 512 bytes", async () => { + const longName = "collection_" + "a".repeat(490); + + try { + const collection = await client.createCollection({ + name: longName, + configuration: { dimension: 3, distance: "cosine" }, + embeddingFunction: null, + }); + + expect(collection.name).toBe(longName); + expect(collection.collectionId).toBeDefined(); + + await client.deleteCollection(longName); + } catch (error) { + // Acceptable if DB has limitations + } + }); + }); + + describe("Embedding Function Persistence", () => { + const testCollections: string[] = []; + + afterAll(async () => { + for (const name of testCollections) { + try { + if (await client.hasCollection(name)) { + await client.deleteCollection(name); + } + } catch (error) { + console.error(`Failed to cleanup collection ${name}:`, error); + } + } + }); + + test("should store default embedding function metadata", async () => { + const name = generateCollectionName("test_ef_default"); + testCollections.push(name); + + await client.createCollection({ + name, + configuration: { dimension: 384 }, + }); + + const metadata = await getCollectionMetadata( + (client as any)._delegate._internal, + name + ); + + expect(metadata).toBeDefined(); + expect(metadata?.settings.embeddingFunction).toBeDefined(); + expect(metadata?.settings.embeddingFunction?.name).toBe("default-embed"); + }); + + test("should store custom embedding function metadata", async () => { + const name = generateCollectionName("test_ef_custom"); + testCollections.push(name); + + const ef = new MockEmbeddingFunction({ + dimension: 3, + model: "test-model", + }); + + await client.createCollection({ + name, + embeddingFunction: ef, + }); + + const metadata = await getCollectionMetadata( + (client as any)._delegate._internal, + name + ); + + expect(metadata).toBeDefined(); + expect(metadata?.settings.embeddingFunction).toBeDefined(); + expect(metadata?.settings.embeddingFunction?.name).toBe("mock-embed"); + expect(metadata?.settings.embeddingFunction?.properties).toEqual({ + dimension: 3, + model: "test-model", + }); + }); + + test("should restore embedding function from metadata", async () => { + const name = generateCollectionName("test_ef_restore"); + testCollections.push(name); + + const ef = new MockEmbeddingFunction({ + dimension: 3, + customParam: "value", + }); + + await client.createCollection({ + name, + embeddingFunction: ef, + }); + + const collection = await client.getCollection({ name }); + + expect(collection.embeddingFunction).toBeDefined(); + expect(collection.embeddingFunction?.name).toBe("mock-embed"); + expect(collection.embeddingFunction?.getConfig()).toEqual({ + dimension: 3, + customParam: "value", + }); + }); + + test("should override stored embedding function when provided explicitly", async () => { + const name = generateCollectionName("test_ef_override"); + testCollections.push(name); + + const ef1 = new MockEmbeddingFunction({ dimension: 3, version: 1 }); + + await client.createCollection({ + name, + embeddingFunction: ef1, + }); + + const ef2 = new MockEmbeddingFunction({ dimension: 3, version: 2 }); + + const collection = await client.getCollection({ + name, + embeddingFunction: ef2, + }); + + expect(collection.embeddingFunction).toBeDefined(); + expect(collection.embeddingFunction).toBe(ef2); + expect(collection.embeddingFunction?.getConfig()).toEqual({ + dimension: 3, + version: 2, + }); + }); + + test("should NOT persist embedding function without buildFromConfig support", async () => { + const name = generateCollectionName("test_ef_no_persist"); + testCollections.push(name); + + const plainObjectEF = { + name: "plain-embed", + async generate(texts: string[]): Promise { + return texts.map(() => [0.1, 0.2, 0.3]); + }, + getConfig() { + return { dimension: 3 }; + }, + }; + + await client.createCollection({ + name, + embeddingFunction: plainObjectEF as any, + }); + + const metadata = await getCollectionMetadata( + (client as any)._delegate._internal, + name + ); + + expect(metadata).toBeDefined(); + expect(metadata?.settings.embeddingFunction).toBeUndefined(); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/collection-name-validation.test.ts b/packages/seekdb/tests/embedded/collection/collection-name-validation.test.ts new file mode 100644 index 0000000..187d834 --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/collection-name-validation.test.ts @@ -0,0 +1,363 @@ +/** + * Embedded mode - Collection name validation (same coverage as server collection-name-validation.test.ts) + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { validateCollectionName } from "../../../src/utils.js"; +import { SeekdbValueError } from "../../../src/errors.js"; +import { SeekdbClient } from "../../../src/client.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("collection-name-validation.test.ts"); + +describe("Embedded Mode - Collection Name Validation", () => { + describe("Valid names", () => { + test("should accept single letter", () => { + expect(() => validateCollectionName("a")).not.toThrow(); + expect(() => validateCollectionName("A")).not.toThrow(); + }); + + test("should accept single digit", () => { + expect(() => validateCollectionName("0")).not.toThrow(); + }); + + test("should accept name with letters, digits, and underscores", () => { + expect(() => validateCollectionName("collection_1")).not.toThrow(); + expect(() => validateCollectionName("MyCollection_123")).not.toThrow(); + }); + + test("should accept maximum length name (512 characters)", () => { + const maxLengthName = "A".repeat(512); + expect(() => validateCollectionName(maxLengthName)).not.toThrow(); + }); + + test("should accept name with all allowed characters", () => { + expect(() => + validateCollectionName("abcdefghijklmnopqrstuvwxyz") + ).not.toThrow(); + expect(() => + validateCollectionName("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + ).not.toThrow(); + expect(() => validateCollectionName("0123456789")).not.toThrow(); + expect(() => validateCollectionName("___")).not.toThrow(); + expect(() => validateCollectionName("test_123_ABC")).not.toThrow(); + }); + }); + + describe("Invalid type", () => { + test("should reject non-string types with SeekdbValueError", () => { + expect(() => validateCollectionName(123 as any)).toThrow( + SeekdbValueError + ); + expect(() => validateCollectionName(123 as any)).toThrow( + "Collection name must be a string, got number" + ); + }); + + test("should reject null with SeekdbValueError", () => { + expect(() => validateCollectionName(null as any)).toThrow( + SeekdbValueError + ); + expect(() => validateCollectionName(null as any)).toThrow( + "Collection name must be a string, got object" + ); + }); + + test("should reject undefined with SeekdbValueError", () => { + expect(() => validateCollectionName(undefined as any)).toThrow( + SeekdbValueError + ); + expect(() => validateCollectionName(undefined as any)).toThrow( + "Collection name must be a string, got undefined" + ); + }); + + test("should reject object with SeekdbValueError", () => { + expect(() => validateCollectionName({} as any)).toThrow(SeekdbValueError); + expect(() => validateCollectionName({ name: "test" } as any)).toThrow( + SeekdbValueError + ); + }); + + test("should reject array with SeekdbValueError", () => { + expect(() => validateCollectionName([] as any)).toThrow(SeekdbValueError); + expect(() => validateCollectionName(["test"] as any)).toThrow( + SeekdbValueError + ); + }); + }); + + describe("Empty name", () => { + test("should reject empty string", () => { + expect(() => validateCollectionName("")).toThrow(SeekdbValueError); + expect(() => validateCollectionName("")).toThrow( + "Collection name must not be empty" + ); + }); + }); + + describe("Name too long", () => { + test("should reject name longer than 512 characters", () => { + const tooLongName = "a".repeat(513); + expect(() => validateCollectionName(tooLongName)).toThrow( + SeekdbValueError + ); + expect(() => validateCollectionName(tooLongName)).toThrow( + /Collection name too long: 513 characters; maximum allowed is 512/ + ); + }); + + test("should reject name much longer than maximum", () => { + const tooLongName = "a".repeat(1000); + expect(() => validateCollectionName(tooLongName)).toThrow( + SeekdbValueError + ); + expect(() => validateCollectionName(tooLongName)).toThrow( + /Collection name too long: 1000 characters; maximum allowed is 512/ + ); + }); + }); + + describe("Invalid characters", () => { + test("should reject name with dash", () => { + expect(() => validateCollectionName("name-with-dash")).toThrow( + SeekdbValueError + ); + expect(() => validateCollectionName("name-with-dash")).toThrow( + /Collection name contains invalid characters.*\[a-zA-Z0-9_\]/ + ); + }); + + test("should reject name with dot", () => { + expect(() => validateCollectionName("name.with.dot")).toThrow( + SeekdbValueError + ); + expect(() => validateCollectionName("name.with.dot")).toThrow( + /Collection name contains invalid characters.*\[a-zA-Z0-9_\]/ + ); + }); + + test("should reject name with space", () => { + expect(() => validateCollectionName("name with space")).toThrow( + SeekdbValueError + ); + expect(() => validateCollectionName("name with space")).toThrow( + /Collection name contains invalid characters.*\[a-zA-Z0-9_\]/ + ); + }); + + test("should reject name with dollar sign", () => { + expect(() => validateCollectionName("name$")).toThrow(SeekdbValueError); + expect(() => validateCollectionName("name$")).toThrow( + /Collection name contains invalid characters.*\[a-zA-Z0-9_\]/ + ); + }); + + test("should reject name with Chinese characters", () => { + expect(() => validateCollectionName("名字")).toThrow(SeekdbValueError); + expect(() => validateCollectionName("名字")).toThrow( + /Collection name contains invalid characters.*\[a-zA-Z0-9_\]/ + ); + }); + + test("should reject name with special characters", () => { + const specialChars = [ + "!", + "@", + "#", + "%", + "^", + "&", + "*", + "(", + ")", + "+", + "=", + "[", + "]", + "{", + "}", + "|", + "\\", + ";", + ":", + "'", + '"', + "<", + ">", + ",", + "?", + "/", + ]; + for (const char of specialChars) { + const name = `test${char}name`; + expect(() => validateCollectionName(name)).toThrow(SeekdbValueError); + expect(() => validateCollectionName(name)).toThrow( + /Collection name contains invalid characters.*\[a-zA-Z0-9_\]/ + ); + } + }); + }); + + describe("Edge cases", () => { + test("should accept name at exactly 512 characters boundary", () => { + const name511 = "a".repeat(511); + const name512 = "a".repeat(512); + const name513 = "a".repeat(513); + expect(() => validateCollectionName(name511)).not.toThrow(); + expect(() => validateCollectionName(name512)).not.toThrow(); + expect(() => validateCollectionName(name513)).toThrow(SeekdbValueError); + }); + + test("should accept underscore at start", () => { + expect(() => validateCollectionName("_test")).not.toThrow(); + }); + + test("should accept underscore at end", () => { + expect(() => validateCollectionName("test_")).not.toThrow(); + }); + + test("should accept digit at start", () => { + expect(() => validateCollectionName("1test")).not.toThrow(); + }); + + test("should accept all underscores", () => { + expect(() => validateCollectionName("___")).not.toThrow(); + }); + }); + + describe("Collection Name Validation Integration", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("collection-name-validation.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("createCollection validation", () => { + test("should reject empty collection name", async () => { + await expect( + client.createCollection({ + name: "", + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + await expect( + client.createCollection({ + name: "", + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow("Collection name must not be empty"); + }); + + test("should reject collection name with dash", async () => { + await expect( + client.createCollection({ + name: "test-collection", + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + await expect( + client.createCollection({ + name: "test-collection", + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(/invalid characters.*\[a-zA-Z0-9_\]/); + }); + + test("should reject collection name with space", async () => { + await expect( + client.createCollection({ + name: "test collection", + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + }); + + test("should reject collection name with special characters", async () => { + await expect( + client.createCollection({ + name: "test@collection", + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + }); + + test("should reject collection name longer than 512 characters", async () => { + const longName = "a".repeat(513); + await expect( + client.createCollection({ + name: longName, + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + await expect( + client.createCollection({ + name: longName, + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(/too long.*513.*maximum.*512/); + }); + + test("should reject non-string collection name", async () => { + await expect( + client.createCollection({ + name: 123 as any, + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + await expect( + client.createCollection({ + name: 123 as any, + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow("Collection name must be a string"); + }); + }); + + describe("getOrCreateCollection validation", () => { + test("should reject empty collection name", async () => { + await expect( + client.getOrCreateCollection({ + name: "", + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + }); + + test("should reject collection name with invalid characters", async () => { + await expect( + client.getOrCreateCollection({ + name: "test.collection", + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + }); + + test("should reject non-string collection name", async () => { + await expect( + client.getOrCreateCollection({ + name: null as any, + configuration: { dimension: 3 }, + embeddingFunction: null, + }) + ).rejects.toThrow(SeekdbValueError); + }); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/collection-query.test.ts b/packages/seekdb/tests/embedded/collection/collection-query.test.ts new file mode 100644 index 0000000..bae09cd --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/collection-query.test.ts @@ -0,0 +1,343 @@ +/** + * Collection query tests - testing collection.query() interface for Embedded mode + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { Collection } from "../../../src/collection.js"; +import { + generateCollectionName, + Simple3DEmbeddingFunction, +} from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("collection-query.test.ts"); + +describe("Embedded Mode - Collection Query Operations", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("collection-query.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("Embedded Mode Collection Query", () => { + let collection: Collection; + let collectionName: string; + + beforeAll(async () => { + collectionName = generateCollectionName("test_query"); + collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3", "id4", "id5"], + embeddings: [ + [1.0, 2.0, 3.0], + [2.0, 3.0, 4.0], + [1.1, 2.1, 3.1], + [2.1, 3.1, 4.1], + [1.2, 2.2, 3.2], + ], + documents: [ + "This is a test document about machine learning", + "Python programming tutorial for beginners", + "Advanced machine learning algorithms", + "Data science with Python", + "Introduction to neural networks", + ], + metadatas: [ + { category: "AI", score: 95, tag: "ml" }, + { category: "Programming", score: 88, tag: "python" }, + { category: "AI", score: 92, tag: "ml" }, + { category: "Data Science", score: 90, tag: "python" }, + { category: "AI", score: 85, tag: "neural" }, + ], + }); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("basic vector similarity query", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + nResults: 3, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + expect(results.ids[0].length).toBeGreaterThan(0); + }); + + test("query with metadata filter using comparison operators", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { score: { $gte: 90 } }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("query with combined filters", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { category: { $eq: "AI" }, score: { $gte: 90 } }, + whereDocument: { $contains: "machine" }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("query with document filter using regex", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + whereDocument: { $regex: ".*[Pp]ython.*" }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("query with $in operator", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { tag: { $in: ["ml", "python"] } }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("query with multiple vectors (returns dict with lists of lists)", async () => { + const queryVector1 = [1.0, 2.0, 3.0]; + const queryVector2 = [2.0, 3.0, 4.0]; + const queryVector3 = [1.1, 2.1, 3.1]; + + const results = await collection.query({ + queryEmbeddings: [queryVector1, queryVector2, queryVector3], + nResults: 2, + }); + + expect(results).toBeDefined(); + expect(typeof results).toBe("object"); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(3); + + for (let i = 0; i < results.ids.length; i++) { + expect(results.ids[i].length).toBeGreaterThan(0); + } + }); + + test("single vector returns dict format", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + nResults: 2, + }); + + expect(results).toBeDefined(); + expect(typeof results).toBe("object"); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(1); + expect(results.ids[0].length).toBeGreaterThan(0); + }); + + test("query with include parameter", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + include: ["documents", "metadatas"], + nResults: 3, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + + if (results.ids[0].length > 0) { + expect(results.documents).toBeDefined(); + expect(results.metadatas).toBeDefined(); + expect(results.ids[0].length).toBe(results.documents![0].length); + expect(results.ids[0].length).toBe(results.metadatas![0].length); + } + }); + + test("query with logical operators ($or)", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { + $or: [{ category: { $eq: "AI" } }, { tag: { $eq: "python" } }], + }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("query with include parameter to get specific fields", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + include: ["documents", "metadatas", "embeddings"], + nResults: 3, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + + if (results.ids[0].length > 0) { + expect(results.documents).toBeDefined(); + expect(results.metadatas).toBeDefined(); + expect(results.embeddings).toBeDefined(); + expect(results.ids[0].length).toBe(results.documents![0].length); + } + }); + + test("query with $ne (not equal) operator", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { category: { $ne: "AI" } }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + if (results.ids[0].length > 0 && results.metadatas) { + for (const metadata of results.metadatas[0]) { + if (metadata) { + expect(metadata.category).not.toBe("AI"); + } + } + } + }); + + test("query with $lt (less than) operator", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { score: { $lt: 90 } }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + if (results.ids[0].length > 0 && results.metadatas) { + for (const metadata of results.metadatas[0]) { + if (metadata && metadata.score !== undefined) { + expect(metadata.score).toBeLessThan(90); + } + } + } + }); + + test("query with $lte (less than or equal) operator", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { score: { $lte: 88 } }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("query with $gt (greater than) operator", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { score: { $gt: 90 } }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + if (results.ids[0].length > 0 && results.metadatas) { + for (const metadata of results.metadatas[0]) { + if (metadata && metadata.score !== undefined) { + expect(metadata.score).toBeGreaterThan(90); + } + } + } + }); + + test("query with $nin (not in) operator", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { tag: { $nin: ["ml", "python"] } }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("query with $and operator combining multiple conditions", async () => { + const queryVector = [1.0, 2.0, 3.0]; + const results = await collection.query({ + queryEmbeddings: queryVector, + where: { + $and: [ + { category: { $eq: "AI" } }, + { score: { $gte: 90 } }, + { tag: { $in: ["ml", "neural"] } }, + ], + }, + nResults: 5, + }); + + expect(results).toBeDefined(); + expect(results.ids).toBeDefined(); + }); + + test("query with queryTexts using embedding function", async () => { + const ef = Simple3DEmbeddingFunction(); + const collectionWithEF = await client.createCollection({ + name: generateCollectionName("test_query_ef"), + embeddingFunction: ef, + }); + + await collectionWithEF.add({ + ids: ["ef1", "ef2"], + documents: ["test document 1", "test document 2"], + }); + + const results = await collectionWithEF.query({ + queryTexts: "test document", + nResults: 2, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + + await client.deleteCollection(collectionWithEF.name); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/column-inference.test.ts b/packages/seekdb/tests/embedded/collection/column-inference.test.ts new file mode 100644 index 0000000..a5311e1 --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/column-inference.test.ts @@ -0,0 +1,259 @@ +/** + * Column name inference tests for embedded mode + * Tests that column names are correctly inferred from SQL statements + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("column-inference.test.ts"); + +describe("Embedded Mode - Column Name Inference", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("column-inference.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + try { + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("SHOW CREATE TABLE column inference", () => { + test("infers column names for SHOW CREATE TABLE", async () => { + const collectionName = generateCollectionName("test_show_create"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // Get the collection again to trigger SHOW CREATE TABLE + const retrieved = await client.getCollection({ + name: collectionName, + embeddingFunction: null, + }); + + // Verify that distance was extracted correctly (this implies column names were inferred) + expect(retrieved).toBeDefined(); + expect(retrieved.distance).toBe("l2"); + expect(retrieved.dimension).toBe(3); + + await client.deleteCollection(collectionName); + }); + + test("infers column names for SHOW CREATE TABLE with different distance metrics", async () => { + const distances: Array<"l2" | "cosine" | "inner_product"> = [ + "l2", + "cosine", + "inner_product", + ]; + + for (const distance of distances) { + const collectionName = generateCollectionName(`test_${distance}`); + await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance }, + embeddingFunction: null, + }); + + const retrieved = await client.getCollection({ + name: collectionName, + embeddingFunction: null, + }); + + // Verify distance extraction (implies column name inference worked) + expect(retrieved.distance).toBe(distance); + + await client.deleteCollection(collectionName); + } + }); + }); + + describe("SHOW TABLES column inference", () => { + test("infers column names for SHOW TABLES", async () => { + const collectionName1 = generateCollectionName("test_show_tables_1"); + const collectionName2 = generateCollectionName("test_show_tables_2"); + + await client.createCollection({ + name: collectionName1, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await client.createCollection({ + name: collectionName2, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // listCollections uses SHOW TABLES internally + const collections = await client.listCollections(); + + // Verify that collections were found (implies column name inference worked) + expect(collections).toBeDefined(); + expect(Array.isArray(collections)).toBe(true); + expect(collections.length).toBeGreaterThanOrEqual(2); + + const names = collections.map((c) => c.name); + expect(names).toContain(collectionName1); + expect(names).toContain(collectionName2); + + await client.deleteCollection(collectionName1); + await client.deleteCollection(collectionName2); + }); + }); + + describe("SELECT statement column inference", () => { + test("infers column names for simple SELECT", async () => { + const collectionName = generateCollectionName("test_select"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + metadatas: [{ key: "value" }], + }); + + // get() uses SELECT internally + const results = await collection.get({ ids: ["id1"] }); + + // Verify that results have correct structure (implies column name inference worked) + expect(results.ids).toBeDefined(); + expect(results.ids[0]).toBe("id1"); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe("test document"); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({ key: "value" }); + + await client.deleteCollection(collectionName); + }); + + test("infers column names for SELECT with specific fields", async () => { + const collectionName = generateCollectionName("test_select_fields"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + }); + + // get() with include parameter uses SELECT with specific fields + const results = await collection.get({ + ids: ["id1"], + include: ["documents"], + }); + + // Verify that only documents are returned (implies column name inference worked) + expect(results.ids).toBeDefined(); + expect(results.documents).toBeDefined(); + expect(results.embeddings).toBeUndefined(); + expect(results.metadatas).toBeUndefined(); + + await client.deleteCollection(collectionName); + }); + }); + + describe("Column inference fallback", () => { + test("handles column name inference failure gracefully", async () => { + const collectionName = generateCollectionName("test_fallback"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + }); + + // Even if column name inference fails, get() should still work + // (it will fallback to col_0, col_1, etc.) + const results = await collection.get({ ids: ["id1"] }); + + // Results should still be accessible + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + + await client.deleteCollection(collectionName); + }); + }); + + describe("Complex SELECT statements", () => { + test("handles SELECT with WHERE clause", async () => { + const collectionName = generateCollectionName("test_select_where"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + metadatas: [{ key: "value1" }, { key: "value2" }], + }); + + // get() with where clause uses SELECT with WHERE + const results = await collection.get({ + where: { key: { $eq: "value1" } }, + }); + + // Verify that filtering worked (implies column name inference worked) + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + + await client.deleteCollection(collectionName); + }); + + test("handles SELECT with LIMIT and OFFSET", async () => { + const collectionName = generateCollectionName("test_select_limit"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + }); + + // get() with limit uses SELECT with LIMIT + const results = await collection.get({ limit: 2 }); + + // Verify that limit worked (implies column name inference worked) + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeLessThanOrEqual(2); + + await client.deleteCollection(collectionName); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/complex-queries.test.ts b/packages/seekdb/tests/embedded/collection/complex-queries.test.ts new file mode 100644 index 0000000..ef806a1 --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/complex-queries.test.ts @@ -0,0 +1,274 @@ +/** + * Complex query scenarios tests for Embedded mode + * Tests advanced query features, filters, and edge cases for embedded mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("complex-queries.test.ts"); + +describe("Embedded Mode - Complex Query Scenarios", () => { + let client: SeekdbClient; + let collectionName: string; + + beforeAll(async () => { + await cleanupTestDb("complex-queries.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + collectionName = generateCollectionName("test_complex_queries"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + await client.close(); + }); + + describe("Complex Metadata Filters", () => { + test("query with nested metadata filter", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + metadatas: [ + { nested: { key: "value1" }, score: 90 }, + { nested: { key: "value2" }, score: 85 }, + { nested: { key: "value1" }, score: 95 }, + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + where: { "nested.key": { $eq: "value1" } }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + }); + + test("query with multiple conditions using $and", async () => { + const name = generateCollectionName("test_and_filter"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + metadatas: [ + { category: "A", score: 90 }, + { category: "B", score: 85 }, + { category: "A", score: 95 }, + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + where: { category: { $eq: "A" }, score: { $gte: 90 } }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThanOrEqual(0); + + await client.deleteCollection(collection.name); + }); + + test("query with $in operator on array", async () => { + const name = generateCollectionName("test_in_filter"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + metadatas: [ + { tags: ["tag1", "tag2"] }, + { tags: ["tag2", "tag3"] }, + { tags: ["tag1", "tag3"] }, + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + where: { tags: { $in: ["tag1"] } }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + + await client.deleteCollection(collection.name); + }); + }); + + describe("Query with Different Distance Metrics", () => { + test("query results differ with different distance metrics", async () => { + const l2Name = generateCollectionName("test_l2_query"); + const cosineName = generateCollectionName("test_cosine_query"); + const l2Collection = await client.createCollection({ + name: l2Name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const cosineCollection = await client.createCollection({ + name: cosineName, + configuration: { dimension: 3, distance: "cosine" }, + embeddingFunction: null, + }); + + const testData = { + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + }; + + await l2Collection.add(testData); + await cosineCollection.add(testData); + + const queryVector = [[1, 0, 0]]; + + const l2Results = await l2Collection.query({ + queryEmbeddings: queryVector, + nResults: 3, + }); + + const cosineResults = await cosineCollection.query({ + queryEmbeddings: queryVector, + nResults: 3, + }); + + expect(l2Results.ids).toBeDefined(); + expect(cosineResults.ids).toBeDefined(); + expect(l2Results.distances).toBeDefined(); + expect(cosineResults.distances).toBeDefined(); + + await client.deleteCollection(l2Collection.name); + await client.deleteCollection(cosineCollection.name); + }); + }); + + describe("Query with Include Parameters", () => { + test("query with include only embeddings", async () => { + const name = generateCollectionName("test_include_emb"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test"], + metadatas: [{ key: "value" }], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 1, + include: ["embeddings"], + }); + + expect(results.embeddings).toBeDefined(); + expect(results.documents).toBeUndefined(); + expect(results.metadatas).toBeUndefined(); + + await client.deleteCollection(collection.name); + }); + + test("query with include documents and metadatas", async () => { + const name = generateCollectionName("test_include_doc_meta"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test"], + metadatas: [{ key: "value" }], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 1, + include: ["documents", "metadatas"], + }); + + expect(results.documents).toBeDefined(); + expect(results.metadatas).toBeDefined(); + expect(results.embeddings).toBeUndefined(); + + await client.deleteCollection(collection.name); + }); + }); + + describe("Query with Multiple Query Vectors", () => { + test("query with multiple query vectors returns multiple result sets", async () => { + const name = generateCollectionName("test_multi_query"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + }); + + const results = await collection.query({ + queryEmbeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + nResults: 2, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(2); + expect(results.ids[0].length).toBeGreaterThan(0); + expect(results.ids[1].length).toBeGreaterThan(0); + + await client.deleteCollection(collection.name); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/hybrid-search-enhanced.test.ts b/packages/seekdb/tests/embedded/collection/hybrid-search-enhanced.test.ts new file mode 100644 index 0000000..463d58f --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/hybrid-search-enhanced.test.ts @@ -0,0 +1,335 @@ +/** + * Enhanced hybrid search tests for Embedded mode + * Tests advanced hybrid search features, RRF (rank), and edge cases for embedded mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("hybrid-search-enhanced.test.ts"); + +describe("Embedded Mode - Enhanced Hybrid Search", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("hybrid-search-enhanced.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + try { + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("Hybrid Search Enhanced", () => { + let collectionName: string; + + beforeAll(async () => { + collectionName = generateCollectionName("test_hybrid_enhanced"); + }); + + describe("Hybrid Search with RRF (Reciprocal Rank Fusion)", () => { + test("hybrid search with rank parameter", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + documents: [ + "machine learning document", + "python programming tutorial", + "data science with python", + ], + }); + + try { + const results = await collection.hybridSearch({ + query: { + whereDocument: { $contains: "machine learning" }, + nResults: 10, + }, + knn: { + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + }, + rank: { rrf: {} }, + nResults: 3, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } finally { + await client.deleteCollection(collection.name); + } + }); + + test("hybrid search without rank parameter", async () => { + const name = generateCollectionName("test_no_rank"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + documents: ["test document 1", "test document 2"], + }); + + try { + const results = await collection.hybridSearch({ + query: { + whereDocument: { $contains: "test" }, + nResults: 10, + }, + knn: { + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + }, + nResults: 2, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThan(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + }); + + describe("Hybrid Search Edge Cases", () => { + test("hybrid search with empty results", async () => { + const name = generateCollectionName("test_empty_results"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + try { + const results = await collection.hybridSearch({ + query: { + whereDocument: { $contains: "test" }, + nResults: 10, + }, + knn: { + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + }, + nResults: 10, + }); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(1); + expect(results.ids[0].length).toBe(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + + test("hybrid search with only text, no vector results", async () => { + const name = generateCollectionName("test_text_only"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + }); + + try { + const results = await collection.hybridSearch({ + query: { + whereDocument: { + $contains: "xyznonexistentnomatch", + }, + nResults: 10, + }, + knn: { + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + } catch (error: any) { + if ( + error.message?.includes("not supported") || + error.message?.includes("Parse error") + ) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + + test("hybrid search with only vector, no text results", async () => { + const name = generateCollectionName("test_vector_only"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + }); + + try { + const results = await collection.hybridSearch({ + query: { + whereDocument: { $contains: "test document" }, + nResults: 10, + }, + knn: { + queryEmbeddings: [[100, 200, 300]], + nResults: 10, + }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + }); + + describe("Hybrid Search with Filters", () => { + test("hybrid search with metadata filter", async () => { + const name = generateCollectionName("test_hybrid_filter"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + documents: ["test document 1", "test document 2"], + metadatas: [{ category: "A" }, { category: "B" }], + }); + + try { + const results = await collection.hybridSearch({ + query: { + whereDocument: { $contains: "test" }, + where: { category: { $eq: "A" } }, + nResults: 10, + }, + knn: { + queryEmbeddings: [[1, 2, 3]], + where: { category: { $eq: "A" } }, + nResults: 10, + }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + + test("hybrid search with whereDocument filter", async () => { + const name = generateCollectionName("test_hybrid_where_doc"); + const collection = await client.createCollection({ + name, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + documents: ["machine learning", "python programming"], + }); + + try { + const results = await collection.hybridSearch({ + query: { + whereDocument: { $contains: "machine" }, + nResults: 10, + }, + knn: { + queryEmbeddings: [[1, 2, 3]], + nResults: 10, + }, + nResults: 10, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBeGreaterThanOrEqual(0); + } catch (error: any) { + if (error.message?.includes("not supported")) { + return; + } + throw error; + } + + await client.deleteCollection(collection.name); + }); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/collection/query-approximate.test.ts b/packages/seekdb/tests/embedded/collection/query-approximate.test.ts new file mode 100644 index 0000000..c45635d --- /dev/null +++ b/packages/seekdb/tests/embedded/collection/query-approximate.test.ts @@ -0,0 +1,118 @@ +/** + * Query approximate parameter tests for Embedded mode + * Tests the approximate parameter in query operations for embedded mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("query-approximate.test.ts"); + +describe("Embedded Mode - Query Approximate Parameter", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("query-approximate.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + try { + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("Approximate Query", () => { + test("query with approximate=true (default)", async () => { + const collectionName = generateCollectionName("test_approximate"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 3, + approximate: true, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + expect(results.distances).toBeDefined(); + + await client.deleteCollection(collectionName); + }); + + test("query with approximate=false", async () => { + const collectionName = generateCollectionName("test_approximate_false"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2", "id3"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 3, + approximate: false, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + expect(results.distances).toBeDefined(); + + await client.deleteCollection(collection.name); + }); + + test("query without approximate parameter (defaults to true)", async () => { + const collectionName = generateCollectionName("test_approximate_default"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 2, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + + await client.deleteCollection(collection.name); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/data/data-normalization.test.ts b/packages/seekdb/tests/embedded/data/data-normalization.test.ts new file mode 100644 index 0000000..317a0e2 --- /dev/null +++ b/packages/seekdb/tests/embedded/data/data-normalization.test.ts @@ -0,0 +1,211 @@ +/** + * Data normalization scenario tests for Embedded mode + * Tests various data formats (VARCHAR wrapper, JSON strings, etc.) for embedded mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("data-normalization.test.ts"); + +describe("Embedded Mode - Data Normalization Scenarios", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("data-normalization.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + try { + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("Metadata Normalization", () => { + test("handles simple metadata", async () => { + const collectionName = generateCollectionName("test_metadata_norm"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + metadatas: [{ key: "value", num: 123 }], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({ key: "value", num: 123 }); + + await client.deleteCollection(collectionName); + }); + + test("handles nested metadata", async () => { + const collectionName = generateCollectionName("test_nested_meta"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + metadatas: [{ nested: { key: "value" }, array: [1, 2, 3] }], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({ + nested: { key: "value" }, + array: [1, 2, 3], + }); + + await client.deleteCollection(collectionName); + }); + + test("handles null metadata", async () => { + const collectionName = generateCollectionName("test_null_meta"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + metadatas: [null], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.metadatas).toBeDefined(); + // Embedded: null metadata may come back as {} (SDK treats null → {} for API stability). + expect([null, {}]).toContainEqual(results.metadatas![0]); + + await client.deleteCollection(collectionName); + }); + + test("handles empty metadata object", async () => { + const collectionName = generateCollectionName("test_empty_meta"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + metadatas: [{}], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({}); + + await client.deleteCollection(collectionName); + }); + }); + + describe("Document Normalization", () => { + test("handles simple document", async () => { + const collectionName = generateCollectionName("test_doc_norm"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: ["test document"], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe("test document"); + + await client.deleteCollection(collectionName); + }); + + test("handles empty document", async () => { + const collectionName = generateCollectionName("test_empty_doc"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: [""], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(""); + + await client.deleteCollection(collectionName); + }); + + test("handles long document", async () => { + const collectionName = generateCollectionName("test_long_doc"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // Embedded mode may have limits on very long text; use 1000 chars to ensure round-trip + const longDoc = "a".repeat(1000); + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + documents: [longDoc], + }); + + const results = await collection.get({ ids: ["id1"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(longDoc); + + await client.deleteCollection(collectionName); + }); + }); + + describe("Embedding Normalization", () => { + test("handles embedding array format", async () => { + const collectionName = generateCollectionName("test_emb_norm"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1.1, 2.2, 3.3]], + }); + + const results = await collection.get({ + ids: ["id1"], + include: ["embeddings"], + }); + expect(results.embeddings).toBeDefined(); + expect(results.embeddings![0]).toEqual([1.1, 2.2, 3.3]); + + await client.deleteCollection(collectionName); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/edge-cases/edge-cases-and-errors.test.ts b/packages/seekdb/tests/embedded/edge-cases/edge-cases-and-errors.test.ts new file mode 100644 index 0000000..1358242 --- /dev/null +++ b/packages/seekdb/tests/embedded/edge-cases/edge-cases-and-errors.test.ts @@ -0,0 +1,387 @@ +/** + * Edge cases and error handling tests for Embedded mode + * Tests boundary conditions, error scenarios, and special cases for embedded mode + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { generateCollectionName } from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; +import { SeekdbValueError } from "../../../src/errors.js"; + +const TEST_CONFIG = getEmbeddedTestConfig("edge-cases-and-errors.test.ts"); + +describe("Embedded Mode - Edge Cases and Error Handling", () => { + describe("Edge Cases", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("edge-cases-and-errors.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + try { + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("Collection Management Edge Cases", () => { + test("createCollection with empty name should fail", async () => { + await expect(async () => { + await client.createCollection({ + name: "", + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + }).rejects.toThrow(); + }); + + test("getCollection with non-existent collection should throw", async () => { + const nonExistentName = generateCollectionName("non_existent"); + await expect(async () => { + await client.getCollection({ + name: nonExistentName, + embeddingFunction: null, + }); + }).rejects.toThrow(); + }); + + test("deleteCollection with non-existent collection should throw", async () => { + const nonExistentName = generateCollectionName("non_existent"); + await expect(async () => { + await client.deleteCollection(nonExistentName); + }).rejects.toThrow(); + }); + + test("hasCollection returns false for non-existent collection", async () => { + const nonExistentName = generateCollectionName("non_existent"); + const exists = await client.hasCollection(nonExistentName); + expect(exists).toBe(false); + }); + }); + + describe("Data Operations Edge Cases", () => { + let collectionName: string; + + beforeAll(async () => { + collectionName = generateCollectionName("test_edge_cases"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("add with empty IDs array should fail", async () => { + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await expect(async () => { + await collection.add({ + ids: [], + embeddings: [[1, 2, 3]], + }); + }).rejects.toThrow(SeekdbValueError); + }); + + test("add with null document should work", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_null_doc"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id_null_doc"], + embeddings: [[1, 2, 3]], + documents: [null as any], + }); + + const results = await collection.get({ ids: ["id_null_doc"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(null); + + await client.deleteCollection(collection.name); + }); + + test("add with empty string document should work", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_doc"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id_empty_doc"], + embeddings: [[1, 2, 3]], + documents: [""], + }); + + const results = await collection.get({ ids: ["id_empty_doc"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(""); + + await client.deleteCollection(collection.name); + }); + + test("add with null metadata should work", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_null_meta"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id_null_meta"], + embeddings: [[1, 2, 3]], + metadatas: [null as any], + }); + + const results = await collection.get({ ids: ["id_null_meta"] }); + expect(results.metadatas).toBeDefined(); + // Embedded: engine may return null for metadata column; SDK treats null → {} so we may get {}. + expect([null, {}]).toContainEqual(results.metadatas![0]); + + await client.deleteCollection(collection.name); + }); + + test("add with empty metadata object should work", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_meta"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id_empty_meta"], + embeddings: [[1, 2, 3]], + metadatas: [{}], + }); + + const results = await collection.get({ ids: ["id_empty_meta"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual({}); + + await client.deleteCollection(collection.name); + }); + + test("get with empty IDs array should return empty results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_empty_ids"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const results = await collection.get({ ids: [] }); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(0); + + await client.deleteCollection(collection.name); + }); + + test("get with non-existent IDs should return empty results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_nonexistent_ids"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const results = await collection.get({ ids: ["non_existent_id"] }); + expect(results.ids).toBeDefined(); + expect(results.ids.length).toBe(0); + + await client.deleteCollection(collection.name); + }); + + test("query with nResults=0 should return empty results", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_query_zero"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2, 3]], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 0, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBe(0); + + await client.deleteCollection(collection.name); + }); + + test("query with nResults larger than collection size should return all", async () => { + const collection = await client.createCollection({ + name: generateCollectionName("test_query_large_n"), + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await collection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1, 2, 3], + [4, 5, 6], + ], + }); + + const results = await collection.query({ + queryEmbeddings: [[1, 2, 3]], + nResults: 100, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeLessThanOrEqual(2); + + await client.deleteCollection(collection.name); + }); + }); + + describe("Special Characters and Encoding", () => { + test("handles Unicode characters in documents", async () => { + const collectionName = generateCollectionName("test_unicode"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const unicodeText = "测试 🚀 中文 日本語 한국어"; + await collection.add({ + ids: ["id_unicode"], + embeddings: [[1, 2, 3]], + documents: [unicodeText], + }); + + const results = await collection.get({ ids: ["id_unicode"] }); + expect(results.documents![0]).toBe(unicodeText); + + await client.deleteCollection(collectionName); + }); + + // C ABI: metadata with newlines/quotes may be truncated or corrupted, or C layer may throw "Invalid JSON text". + test("handles special characters in metadata", async () => { + const collectionName = generateCollectionName("test_special_chars"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const specialMetadata = { + "key with spaces": "value", + "key-with-dashes": "value", + key_with_underscores: "value", + "key.with.dots": "value", + "key:with:colons": "value", + 'key"with"quotes': "value", + "key'with'quotes": "value", + "key\nwith\nnewlines": "value", + }; + + try { + await collection.add({ + ids: ["id_special"], + embeddings: [[1, 2, 3]], + metadatas: [specialMetadata], + }); + const results = await collection.get({ ids: ["id_special"] }); + expect(results.metadatas).toBeDefined(); + expect(results.metadatas![0]).toEqual(specialMetadata); + } catch (e: any) { + // Embedded: C/engine may throw "Invalid JSON text" when metadata contains special chars; accept as known limitation. + const msg = String(e?.message ?? e ?? "").toLowerCase(); + expect(msg).toMatch(/invalid json|json text/); + } finally { + await client.deleteCollection(collectionName).catch(() => {}); + } + }); + + // Embedded: 100KB supported via STRING→MEDIUMTEXT; session ob_default_lob_inrow_threshold set on connect so LOB in-row; C ABI read_lob_data for out-of-row. + test("handles very long document", async () => { + const collectionName = generateCollectionName("test_long_doc"); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const longDoc = "a".repeat(100000); // 100KB document + await collection.add({ + ids: ["id_long"], + embeddings: [[1, 2, 3]], + documents: [longDoc], + }); + + const results = await collection.get({ ids: ["id_long"] }); + expect(results.documents).toBeDefined(); + expect(results.documents![0]).toBe(longDoc); + expect((results.documents![0] as string).length).toBe(100000); + + await client.deleteCollection(collectionName); + }); + }); + }); + + describe("Error Recovery and Resilience", () => { + let client: SeekdbClient; + + beforeAll(async () => { + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + try { + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("operations work after error", async () => { + const collectionName = generateCollectionName("test_recovery"); + + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // Try invalid operation first + await expect(async () => { + await collection.add({ + ids: ["id1"], + embeddings: [[1, 2]], // Wrong dimension + }); + }).rejects.toThrow(); + + // After error, valid operation should still work + await collection.add({ + ids: ["id2"], + embeddings: [[1, 2, 3]], // Correct dimension + }); + + const results = await collection.get({ ids: ["id2"] }); + expect(results.ids.length).toBe(1); + + await client.deleteCollection(collectionName); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/embedding/collection-embedding-function.test.ts b/packages/seekdb/tests/embedded/embedding/collection-embedding-function.test.ts new file mode 100644 index 0000000..40efbd6 --- /dev/null +++ b/packages/seekdb/tests/embedded/embedding/collection-embedding-function.test.ts @@ -0,0 +1,147 @@ +/** + * Test collection creation with embedding function - testing create_collection, + * get_or_create_collection, and get_collection interfaces with embedding function handling for Embedded mode + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import type { HNSWConfiguration } from "../../../src/types.js"; +import { + generateCollectionName, + Simple3DEmbeddingFunction, +} from "../../test-utils.js"; +import { SeekdbValueError } from "../../../src/errors.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +const TEST_CONFIG = getEmbeddedTestConfig( + "collection-embedding-function.test.ts" +); + +describe("Embedded Mode - Collection Embedding Function Tests", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("collection-embedding-function.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("createCollection tests", () => { + test("createCollection with embeddingFunction=null and explicit configuration", async () => { + const collectionName = generateCollectionName("test_explicit_none"); + const config: HNSWConfiguration = { dimension: 3, distance: "cosine" }; + const collection = await client.createCollection({ + name: collectionName, + configuration: config, + embeddingFunction: null, + }); + + expect(collection).toBeDefined(); + expect(collection.name).toBe(collectionName); + expect(collection.dimension).toBe(3); + expect(collection.distance).toBe("cosine"); + expect(collection.embeddingFunction).toBeUndefined(); + + await client.deleteCollection(collectionName); + }); + + test("createCollection with custom embedding function", async () => { + const collectionName = generateCollectionName("test_custom_ef"); + const ef = Simple3DEmbeddingFunction(); + const collection = await client.createCollection({ + name: collectionName, + embeddingFunction: ef, + }); + + expect(collection).toBeDefined(); + expect(collection.name).toBe(collectionName); + expect(collection.dimension).toBe(3); + expect(collection.embeddingFunction).toBe(ef); + + // Test adding documents without explicit embeddings + await collection.add({ + ids: "ef_doc1", + documents: "Test document for embedding", + }); + + const results = await collection.get({ ids: "ef_doc1" }); + expect(results.ids).toContain("ef_doc1"); + expect(results.embeddings).toBeDefined(); + + await client.deleteCollection(collectionName); + }); + + test("createCollection with embedding function and explicit dimension mismatch", async () => { + const collectionName = generateCollectionName("test_ef_dim_mismatch"); + const ef = Simple3DEmbeddingFunction(); + + await expect( + client.createCollection({ + name: collectionName, + configuration: { dimension: 128 }, // Mismatch with 3D embedding function + embeddingFunction: ef, + }) + ).rejects.toThrow(SeekdbValueError); + }); + + test("createCollection with embedding function and matching dimension", async () => { + const collectionName = generateCollectionName("test_ef_dim_match"); + const ef = Simple3DEmbeddingFunction(); + const collection = await client.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: ef, + }); + + expect(collection.dimension).toBe(3); + expect(collection.embeddingFunction).toBe(ef); + + await client.deleteCollection(collectionName); + }); + }); + + describe("getOrCreateCollection tests", () => { + test("getOrCreateCollection with embedding function", async () => { + const collectionName = generateCollectionName("test_get_or_create_ef"); + const ef = Simple3DEmbeddingFunction(); + const collection = await client.getOrCreateCollection({ + name: collectionName, + embeddingFunction: ef, + }); + + expect(collection).toBeDefined(); + expect(collection.embeddingFunction).toBe(ef); + expect(collection.dimension).toBe(3); + + await client.deleteCollection(collectionName); + }); + }); + + describe("query with embedding function", () => { + test("query with queryTexts using embedding function", async () => { + const collectionName = generateCollectionName("test_ef_query"); + const ef = Simple3DEmbeddingFunction(); + const collection = await client.createCollection({ + name: collectionName, + embeddingFunction: ef, + }); + + await collection.add({ + ids: ["ef_q1", "ef_q2"], + documents: ["Document about AI", "Document about Python"], + }); + + const results = await collection.query({ + queryTexts: "AI", + nResults: 2, + }); + + expect(results.ids).toBeDefined(); + expect(results.ids[0].length).toBeGreaterThan(0); + + await client.deleteCollection(collectionName); + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/embedding/default-embedding-function.test.ts b/packages/seekdb/tests/embedded/embedding/default-embedding-function.test.ts new file mode 100644 index 0000000..51d8d9b --- /dev/null +++ b/packages/seekdb/tests/embedded/embedding/default-embedding-function.test.ts @@ -0,0 +1,76 @@ +/** + * Test default embedding function - testing collection creation with default embedding function, + * automatic vector generation from documents, and hybrid search for Embedded mode + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { + generateCollectionName, + registerTestDefaultEmbeddingFunction, +} from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +// Register test default embedding function before any tests run +registerTestDefaultEmbeddingFunction(); + +const TEST_CONFIG = getEmbeddedTestConfig("default-embedding-function.test.ts"); + +describe("Embedded Mode - Default Embedding Function Tests", () => { + let client: SeekdbClient; + + beforeAll(async () => { + await cleanupTestDb("default-embedding-function.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + }); + + afterAll(async () => { + await client.close(); + }); + + test("embedded mode default embedding function", async () => { + const collectionName = generateCollectionName("test_default_ef"); + + // Not providing embeddingFunction should use DefaultEmbeddingFunction + const collection = await client.createCollection({ + name: collectionName, + }); + + expect(collection).toBeDefined(); + expect(collection.name).toBe(collectionName); + expect(collection.embeddingFunction).toBeDefined(); + + // Default embedding function should have dimension 384 + expect(collection.dimension).toBe(384); + + // Test adding documents without explicit embeddings + const testDocuments = [ + "This is a test document about machine learning", + "Python programming tutorial for beginners", + "Advanced machine learning algorithms", + ]; + + const testIds = testDocuments.map((_, i) => `doc_${i}_${Date.now()}`); + const testMetadatas = [ + { category: "AI", score: 95 }, + { category: "Programming", score: 88 }, + { category: "AI", score: 92 }, + ]; + + await collection.add({ + ids: testIds, + documents: testDocuments, + metadatas: testMetadatas, + }); + + // Test query with queryTexts (using the default embedding function) + const results = await collection.query({ + queryTexts: [testDocuments[0]], + nResults: 1, + }); + + expect(results.documents).toBeDefined(); + expect(results.documents!.length).toBeGreaterThan(0); + + await client.deleteCollection(collectionName); + }, 120000); // 2 minutes timeout for creating the collection +}); diff --git a/packages/seekdb/tests/embedded/examples/official-example.test.ts b/packages/seekdb/tests/embedded/examples/official-example.test.ts new file mode 100644 index 0000000..37957e0 --- /dev/null +++ b/packages/seekdb/tests/embedded/examples/official-example.test.ts @@ -0,0 +1,114 @@ +/** + * Official example test case - verifies the documented quick-start workflow for Embedded mode + * + * The scenario covers: + * 1. Creating an embedded client + * 2. Creating a collection via getOrCreateCollection + * 3. Upserting documents/metadatas/ids (relying on default embedding function) + * 4. Querying with queryTexts + metadata filter + document filter + */ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../../src/client.js"; +import { Collection } from "../../../src/collection.js"; +import { + generateCollectionName, + registerTestDefaultEmbeddingFunction, +} from "../../test-utils.js"; +import { getEmbeddedTestConfig, cleanupTestDb } from "../test-utils.js"; + +// Register test default embedding function before any tests run +registerTestDefaultEmbeddingFunction(); + +const TEST_CONFIG = getEmbeddedTestConfig("official-example.test.ts"); + +const PRODUCT_DOCUMENTS = [ + "Laptop Pro with 16GB RAM, 512GB SSD, and high-speed processor", + "Gaming Laptop with 32GB RAM, 1TB SSD, and high-performance graphics", + "Business Ultrabook with 8GB RAM, 256GB SSD, and long battery life", + "Tablet with 6GB RAM, 128GB storage, and 10-inch display", +]; + +const PRODUCT_METADATA = [ + { + category: "laptop", + ram: 16, + storage: 512, + price: 12000, + type: "professional", + }, + { category: "laptop", ram: 32, storage: 1000, price: 25000, type: "gaming" }, + { + category: "laptop", + ram: 8, + storage: 256, + price: 8000, + type: "business", + }, + { category: "tablet", ram: 6, storage: 128, price: 5000, type: "consumer" }, +]; + +describe("Embedded Mode - Official Example", () => { + let client: SeekdbClient; + let collection: Collection; + let collectionName: string; + + beforeAll(async () => { + await cleanupTestDb("official-example.test.ts"); + client = new SeekdbClient(TEST_CONFIG); + collectionName = generateCollectionName("official_example"); + }); + + afterAll(async () => { + try { + await client.deleteCollection(collectionName); + } catch { + // Ignore cleanup errors + } + await client.close(); + }); + + test("official example workflow", async () => { + // Step 1: Create collection via getOrCreateCollection + collection = await client.getOrCreateCollection({ + name: collectionName, + }); + + expect(collection).toBeDefined(); + expect(collection.name).toBe(collectionName); + + // Step 2: Upsert documents with metadata + const productIds = PRODUCT_DOCUMENTS.map((_, i) => `product_${i}`); + await collection.upsert({ + ids: productIds, + documents: PRODUCT_DOCUMENTS, + metadatas: PRODUCT_METADATA, + }); + + // Step 3: Query with queryTexts + const queryResults = await collection.query({ + queryTexts: "high-performance laptop", + nResults: 2, + }); + + expect(queryResults).toBeDefined(); + expect(queryResults.ids).toBeDefined(); + expect(queryResults.ids[0].length).toBeGreaterThan(0); + + // Step 4: Query with metadata filter + const filteredResults = await collection.query({ + queryTexts: "laptop", + nResults: 3, + where: { category: { $eq: "laptop" } }, + }); + + expect(filteredResults).toBeDefined(); + expect(filteredResults.ids[0].length).toBeGreaterThan(0); + + // Verify all results have category "laptop" + if (filteredResults.metadatas && filteredResults.metadatas[0]) { + filteredResults.metadatas[0].forEach((meta: any) => { + expect(meta.category).toBe("laptop"); + }); + } + }, 120000); // 2 minutes timeout +}); diff --git a/packages/seekdb/tests/embedded/mode-consistency.test.ts b/packages/seekdb/tests/embedded/mode-consistency.test.ts new file mode 100644 index 0000000..6e46f66 --- /dev/null +++ b/packages/seekdb/tests/embedded/mode-consistency.test.ts @@ -0,0 +1,389 @@ +/** + * Mode consistency tests (embedded + server) + * Compares behavior between embedded and server modes to ensure they are functionally identical. + * Lives under embedded/ because it requires the native addon. + */ + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { SeekdbClient } from "../../src/client.js"; +import { Client } from "../../src/factory.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; +import { getTestDbDir, cleanupTestDb } from "./test-utils.js"; +import type { SeekdbClient as SeekdbClientType } from "../../src/client.js"; + +describe("Mode Consistency Tests", () => { + describe("Collection Creation and Retrieval", () => { + let serverClient: SeekdbClient; + let embeddedClient: SeekdbClientType; + const TEST_DB_DIR = getTestDbDir("mode-consistency.test.ts"); + + beforeAll(async () => { + serverClient = new SeekdbClient(TEST_CONFIG); + await cleanupTestDb("mode-consistency.test.ts"); + embeddedClient = Client({ + path: TEST_DB_DIR, + database: "test", + }); + }); + + afterAll(async () => { + try { + await serverClient.close(); + await embeddedClient.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("getCollection returns same distance for both modes", async () => { + const collectionName = generateCollectionName("test_distance"); + const distance = "l2"; + + // Create in server mode + await serverClient.createCollection({ + name: collectionName, + configuration: { dimension: 128, distance }, + embeddingFunction: null, + }); + + const serverCollection = await serverClient.getCollection({ + name: collectionName, + embeddingFunction: null, + }); + + // Create in embedded mode + await embeddedClient.createCollection({ + name: collectionName, + configuration: { dimension: 128, distance }, + embeddingFunction: null, + }); + + const embeddedCollection = await embeddedClient.getCollection({ + name: collectionName, + embeddingFunction: null, + }); + + // Both should return the same distance + expect(serverCollection.distance).toBe(distance); + expect(embeddedCollection.distance).toBe(distance); + expect(serverCollection.distance).toBe(embeddedCollection.distance); + + await serverClient.deleteCollection(collectionName); + await embeddedClient.deleteCollection(collectionName); + }); + + test("getCollection returns same dimension for both modes", async () => { + const collectionName = generateCollectionName("test_dimension"); + const dimension = 256; + + // Create in server mode + await serverClient.createCollection({ + name: collectionName, + configuration: { dimension, distance: "l2" }, + embeddingFunction: null, + }); + + const serverCollection = await serverClient.getCollection({ + name: collectionName, + embeddingFunction: null, + }); + + // Create in embedded mode + await embeddedClient.createCollection({ + name: collectionName, + configuration: { dimension, distance: "l2" }, + embeddingFunction: null, + }); + + const embeddedCollection = await embeddedClient.getCollection({ + name: collectionName, + embeddingFunction: null, + }); + + // Both should return the same dimension + expect(serverCollection.dimension).toBe(dimension); + expect(embeddedCollection.dimension).toBe(dimension); + expect(serverCollection.dimension).toBe(embeddedCollection.dimension); + + await serverClient.deleteCollection(collectionName); + await embeddedClient.deleteCollection(collectionName); + }); + + test("listCollections returns same structure for both modes", async () => { + const collectionName1 = generateCollectionName("test_list_1"); + const collectionName2 = generateCollectionName("test_list_2"); + + // Create collections in server mode + await serverClient.createCollection({ + name: collectionName1, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await serverClient.createCollection({ + name: collectionName2, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + // Create collections in embedded mode + await embeddedClient.createCollection({ + name: collectionName1, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await embeddedClient.createCollection({ + name: collectionName2, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + const serverCollections = await serverClient.listCollections(); + const embeddedCollections = await embeddedClient.listCollections(); + + // Both should return arrays + expect(Array.isArray(serverCollections)).toBe(true); + expect(Array.isArray(embeddedCollections)).toBe(true); + + // Both should contain the collections we created + const serverNames = serverCollections.map((c) => c.name); + const embeddedNames = embeddedCollections.map((c) => c.name); + + expect(serverNames).toContain(collectionName1); + expect(serverNames).toContain(collectionName2); + expect(embeddedNames).toContain(collectionName1); + expect(embeddedNames).toContain(collectionName2); + + await serverClient.deleteCollection(collectionName1); + await serverClient.deleteCollection(collectionName2); + await embeddedClient.deleteCollection(collectionName1); + await embeddedClient.deleteCollection(collectionName2); + }); + }); + + describe("Data Operations Consistency", () => { + let serverClient: SeekdbClient; + let embeddedClient: SeekdbClientType; + const TEST_DB_DIR = getTestDbDir("mode-consistency.test.ts"); + + beforeAll(async () => { + serverClient = new SeekdbClient(TEST_CONFIG); + await cleanupTestDb("mode-consistency.test.ts"); + embeddedClient = Client({ + path: TEST_DB_DIR, + database: "test", + }); + }); + + afterAll(async () => { + try { + await serverClient.close(); + await embeddedClient.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("get() returns same normalized data for both modes", async () => { + const collectionName = generateCollectionName("test_get_consistency"); + const testId = "test_id_1"; + const testDocument = "test document"; + const testMetadata = { key: "value", num: 123 }; + const testEmbedding = [1.0, 2.0, 3.0]; + + // Create and add data in server mode + const serverCollection = await serverClient.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await serverCollection.add({ + ids: [testId], + embeddings: [testEmbedding], + documents: [testDocument], + metadatas: [testMetadata], + }); + + // Create and add data in embedded mode + const embeddedCollection = await embeddedClient.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await embeddedCollection.add({ + ids: [testId], + embeddings: [testEmbedding], + documents: [testDocument], + metadatas: [testMetadata], + }); + + // Get data from both modes + const serverResults = await serverCollection.get({ ids: [testId] }); + const embeddedResults = await embeddedCollection.get({ ids: [testId] }); + + // Both should return the same normalized data + expect(serverResults.ids[0]).toBe(testId); + expect(embeddedResults.ids[0]).toBe(testId); + expect(serverResults.ids[0]).toBe(embeddedResults.ids[0]); + + expect(serverResults.documents![0]).toBe(testDocument); + expect(embeddedResults.documents![0]).toBe(testDocument); + expect(serverResults.documents![0]).toBe(embeddedResults.documents![0]); + + expect(serverResults.metadatas![0]).toEqual(testMetadata); + expect(embeddedResults.metadatas![0]).toEqual(testMetadata); + expect(serverResults.metadatas![0]).toEqual( + embeddedResults.metadatas![0] + ); + + // Embeddings should be the same (within floating point precision) + expect(serverResults.embeddings![0]).toEqual(testEmbedding); + expect(embeddedResults.embeddings![0]).toEqual(testEmbedding); + + await serverClient.deleteCollection(collectionName); + await embeddedClient.deleteCollection(collectionName); + }); + + test("query() returns same structure for both modes", async () => { + const collectionName = generateCollectionName("test_query_consistency"); + const testEmbedding = [1.0, 2.0, 3.0]; + + // Create and add data in server mode + const serverCollection = await serverClient.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await serverCollection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1.0, 2.0, 3.0], + [2.0, 3.0, 4.0], + ], + documents: ["doc1", "doc2"], + }); + + // Create and add data in embedded mode + const embeddedCollection = await embeddedClient.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance: "l2" }, + embeddingFunction: null, + }); + + await embeddedCollection.add({ + ids: ["id1", "id2"], + embeddings: [ + [1.0, 2.0, 3.0], + [2.0, 3.0, 4.0], + ], + documents: ["doc1", "doc2"], + }); + + // Query both modes + const serverResults = await serverCollection.query({ + queryEmbeddings: [testEmbedding], + nResults: 2, + }); + + const embeddedResults = await embeddedCollection.query({ + queryEmbeddings: [testEmbedding], + nResults: 2, + }); + + // Both should return the same structure + expect(serverResults.ids).toBeDefined(); + expect(embeddedResults.ids).toBeDefined(); + expect(Array.isArray(serverResults.ids[0])).toBe(true); + expect(Array.isArray(embeddedResults.ids[0])).toBe(true); + + expect(serverResults.distances).toBeDefined(); + expect(embeddedResults.distances).toBeDefined(); + expect(Array.isArray(serverResults.distances![0])).toBe(true); + expect(Array.isArray(embeddedResults.distances![0])).toBe(true); + + expect(serverResults.documents).toBeDefined(); + expect(embeddedResults.documents).toBeDefined(); + + // Both should return results + expect(serverResults.ids[0].length).toBeGreaterThan(0); + expect(embeddedResults.ids[0].length).toBeGreaterThan(0); + + await serverClient.deleteCollection(collectionName); + await embeddedClient.deleteCollection(collectionName); + }); + }); + + describe("Distance Metric Consistency", () => { + let serverClient: SeekdbClient; + let embeddedClient: SeekdbClientType; + const TEST_DB_DIR = getTestDbDir("mode-consistency.test.ts"); + + beforeAll(async () => { + serverClient = new SeekdbClient(TEST_CONFIG); + await cleanupTestDb("mode-consistency.test.ts"); + embeddedClient = Client({ + path: TEST_DB_DIR, + database: "test", + }); + }); + + afterAll(async () => { + try { + await serverClient.close(); + await embeddedClient.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + // Ignore cleanup errors + } + }); + + test("all distance metrics work consistently", async () => { + const distances: Array<"l2" | "cosine" | "inner_product"> = [ + "l2", + "cosine", + "inner_product", + ]; + + for (const distance of distances) { + const collectionName = generateCollectionName(`test_${distance}`); + + // Create in server mode + await serverClient.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance }, + embeddingFunction: null, + }); + + const serverCollection = await serverClient.getCollection({ + name: collectionName, + embeddingFunction: null, + }); + + // Create in embedded mode + await embeddedClient.createCollection({ + name: collectionName, + configuration: { dimension: 3, distance }, + embeddingFunction: null, + }); + + const embeddedCollection = await embeddedClient.getCollection({ + name: collectionName, + embeddingFunction: null, + }); + + // Both should return the same distance + expect(serverCollection.distance).toBe(distance); + expect(embeddedCollection.distance).toBe(distance); + + await serverClient.deleteCollection(collectionName); + await embeddedClient.deleteCollection(collectionName); + } + }); + }); +}); diff --git a/packages/seekdb/tests/embedded/test-utils.ts b/packages/seekdb/tests/embedded/test-utils.ts new file mode 100644 index 0000000..187d280 --- /dev/null +++ b/packages/seekdb/tests/embedded/test-utils.ts @@ -0,0 +1,127 @@ +/** + * Test utilities for embedded mode tests + * Provides common configuration and helper functions + */ + +import * as path from "node:path"; +import * as fs from "node:fs/promises"; + +// Base test database directory (relative path for normal runs; use path.resolve(process.cwd(), "seekdb.db") to verify absolute path with updated .so) +const TEST_DB_BASE_DIR = "./tests/embedded/seekdb.db"; + +/** When set (e.g. SEEKDB_EMBED_SAME_PATH=1), all embedded tests use the same path to verify no cross-path state. */ +const USE_SAME_PATH = + process.env.SEEKDB_EMBED_SAME_PATH === "1" || + process.env.SEEKDB_EMBED_SAME_PATH === "true"; + +/** + * Get test database directory for a specific test file + * Each test file gets its own isolated database directory to avoid conflicts. + * When SEEKDB_EMBED_SAME_PATH=1, all tests use TEST_DB_BASE_DIR (same path) for verification. + */ +export function getTestDbDir(testFileName: string): string { + if (USE_SAME_PATH) return TEST_DB_BASE_DIR; + const baseName = path.basename(testFileName, ".test.ts"); + return path.join(TEST_DB_BASE_DIR, baseName); +} + +/** + * Get embedded test config for use with new SeekdbClient(TEST_CONFIG). + * Aligns with server tests which use new SeekdbClient(TEST_CONFIG). + * For admin ops (createDatabase, listDatabases, etc.), embedded client uses built-in admin database internally. + */ +export function getEmbeddedTestConfig(testFileName: string): { + path: string; + database: string; +} { + return { path: getTestDbDir(testFileName), database: "test" }; +} + +/** Base dir for absolute-path tests (same logical dir as relative, but absolute). */ +const ABSOLUTE_TEST_DB_BASE_DIR = path.resolve(process.cwd(), "seekdb.db"); + +/** + * Get test database directory as **absolute path** for a specific test file. + * Used by absolute-path.test.ts to verify .so / C ABI with absolute path (SeekdbClient & AdminClient). + * When SEEKDB_EMBED_SAME_PATH=1, all tests use ABSOLUTE_TEST_DB_BASE_DIR (same path). + */ +export function getAbsoluteTestDbDir(testFileName: string): string { + if (USE_SAME_PATH) return ABSOLUTE_TEST_DB_BASE_DIR; + const baseName = path.basename(testFileName, ".test.ts"); + return path.join(ABSOLUTE_TEST_DB_BASE_DIR, baseName); +} + +/** + * Get embedded test config with **absolute path** (for absolute-path-only tests). + */ +export function getEmbeddedTestConfigAbsolute(testFileName: string): { + path: string; + database: string; +} { + return { path: getAbsoluteTestDbDir(testFileName), database: "test" }; +} + +/** + * Clean up test database directory for absolute-path tests. + */ +export async function cleanupTestDbAbsolute( + testFileName: string +): Promise { + const testDbDir = getAbsoluteTestDbDir(testFileName); + await waitForDbCleanup(); + const maxRetries = 5; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await fs.rm(testDbDir, { recursive: true, force: true }); + return; + } catch (error: any) { + if (attempt === maxRetries - 1) return; + const delay = Math.min(100 * Math.pow(2, attempt), 1000); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +/** + * Wait for a short period to ensure database operations complete + */ +async function waitForDbCleanup(): Promise { + // Wait a bit to ensure database files are fully closed + await new Promise((resolve) => setTimeout(resolve, 100)); +} + +/** + * Clean up test database directory for a specific test file + * Includes retry logic to handle cases where database is still closing + */ +export async function cleanupTestDb(testFileName: string): Promise { + const testDbDir = getTestDbDir(testFileName); + + // Wait a bit before attempting cleanup + await waitForDbCleanup(); + + // Retry cleanup with exponential backoff + const maxRetries = 5; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await fs.rm(testDbDir, { recursive: true, force: true }); + // Success, exit retry loop + return; + } catch (error: any) { + // If it's the last attempt, ignore the error + if (attempt === maxRetries - 1) { + // Ignore if directory doesn't exist or other errors on final attempt + return; + } + // Wait before retry with exponential backoff + const delay = Math.min(100 * Math.pow(2, attempt), 1000); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +/** + * Legacy function for backward compatibility + * @deprecated Use cleanupTestDb(testFileName) instead + */ +export const TEST_DB_DIR = TEST_DB_BASE_DIR; diff --git a/packages/seekdb/tests/collection-embedding-function.test.ts b/packages/seekdb/tests/embedding/collection-embedding-function.test.ts similarity index 96% rename from packages/seekdb/tests/collection-embedding-function.test.ts rename to packages/seekdb/tests/embedding/collection-embedding-function.test.ts index b3deb4c..956f443 100644 --- a/packages/seekdb/tests/collection-embedding-function.test.ts +++ b/packages/seekdb/tests/embedding/collection-embedding-function.test.ts @@ -3,16 +3,23 @@ * get_or_create_collection, and get_collection interfaces with embedding function handling */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; -import type { HNSWConfiguration } from "../src/types.js"; -import { TEST_CONFIG, generateCollectionName } from "./test-utils.js"; -import { Simple3DEmbeddingFunction } from "./test-utils.js"; +import { SeekdbClient } from "../../src/client.js"; +import type { HNSWConfiguration } from "../../src/types.js"; +import { + TEST_CONFIG, + generateCollectionName, + Simple3DEmbeddingFunction, + registerTestDefaultEmbeddingFunction, +} from "../test-utils.js"; import { registerEmbeddingFunction, getEmbeddingFunction, supportsPersistence, -} from "../src/embedding-function.js"; -import type { EmbeddingFunction } from "../src/types.js"; +} from "../../src/embedding-function.js"; +import type { EmbeddingFunction } from "../../src/types.js"; + +// Register test default embedding function before any tests run +registerTestDefaultEmbeddingFunction(); describe("Collection Embedding Function Tests", () => { let client: SeekdbClient; @@ -22,7 +29,7 @@ describe("Collection Embedding Function Tests", () => { // preload default embedding function try { - const defaultEf = await getEmbeddingFunction("default"); + const defaultEf = await getEmbeddingFunction("default-embed"); console.log("Default embedding function preloaded successfully"); // test if the model is loaded await defaultEf.generate(["test"]); @@ -378,7 +385,7 @@ describe("Collection Embedding Function Tests", () => { return texts.map(() => [0.1, 0.2, 0.3, 0.4]); } getConfig() { - return this.config; + return this.config ?? { dimension: 4 }; } static buildFromConfig(config: any): EmbeddingFunction { return new CustomModel(config); diff --git a/packages/seekdb/tests/default-embedding-function.test.ts b/packages/seekdb/tests/embedding/default-embedding-function.test.ts similarity index 98% rename from packages/seekdb/tests/default-embedding-function.test.ts rename to packages/seekdb/tests/embedding/default-embedding-function.test.ts index 3f4ee44..9a4ed6e 100644 --- a/packages/seekdb/tests/default-embedding-function.test.ts +++ b/packages/seekdb/tests/embedding/default-embedding-function.test.ts @@ -3,8 +3,8 @@ * automatic vector generation from documents, and hybrid search */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; -import { TEST_CONFIG, generateCollectionName } from "./test-utils.js"; +import { SeekdbClient } from "../../src/client.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; import { DefaultEmbeddingFunction } from "@seekdb/default-embed"; describe("Default Embedding Function Tests", () => { diff --git a/packages/seekdb/tests/official-example.test.ts b/packages/seekdb/tests/examples/official-example.test.ts similarity index 94% rename from packages/seekdb/tests/official-example.test.ts rename to packages/seekdb/tests/examples/official-example.test.ts index 381bb16..786c4ec 100644 --- a/packages/seekdb/tests/official-example.test.ts +++ b/packages/seekdb/tests/examples/official-example.test.ts @@ -8,9 +8,9 @@ * 4. Querying with queryTexts + metadata filter + document filter */ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { SeekdbClient } from "../src/client.js"; -import { Collection } from "../src/collection.js"; -import { TEST_CONFIG, generateCollectionName } from "./test-utils.js"; +import { SeekdbClient } from "../../src/client.js"; +import { Collection } from "../../src/collection.js"; +import { TEST_CONFIG, generateCollectionName } from "../test-utils.js"; const PRODUCT_DOCUMENTS = [ "Laptop Pro with 16GB RAM, 512GB SSD, and high-speed processor", diff --git a/packages/seekdb/tests/test-utils.ts b/packages/seekdb/tests/test-utils.ts index 8fbb905..339470a 100644 --- a/packages/seekdb/tests/test-utils.ts +++ b/packages/seekdb/tests/test-utils.ts @@ -4,6 +4,10 @@ */ import type { EmbeddingFunction, EmbeddingConfig } from "../src/types.js"; +import { + registerEmbeddingFunction, + isEmbeddingFunctionRegistered, +} from "../src/embedding-function.js"; /** * Get test configuration based on test mode @@ -128,6 +132,41 @@ function simpleHash(str: string): number { return Math.abs(hash); } +/** + * Test default embedding function for testing + * Manually register a simple default embedding function to avoid module resolution issues with @seekdb/default-embed + */ +export class TestDefaultEmbeddingFunction implements EmbeddingFunction { + readonly name = "default-embed"; + + async generate(texts: string[]): Promise { + return texts.map(() => + Array(384) + .fill(0) + .map(() => Math.random()) + ); + } + + getConfig(): EmbeddingConfig { + return { dimension: 384 }; + } + + static buildFromConfig(): EmbeddingFunction { + return new TestDefaultEmbeddingFunction(); + } +} + +/** + * Register the test default embedding function + * Call this function at the top of test files that need the default embedding function + * This function is idempotent - it will skip registration if already registered + */ +export function registerTestDefaultEmbeddingFunction(): void { + if (!isEmbeddingFunctionRegistered("default-embed")) { + registerEmbeddingFunction("default-embed", TestDefaultEmbeddingFunction); + } +} + /** * Mock Embedding Function for testing * Supports configurable dimension and custom parameters @@ -150,7 +189,6 @@ export class MockEmbeddingFunction implements EmbeddingFunction { } async generate(texts: string[]): Promise { - // Generate mock embeddings based on dimension in config or default 3 const dim = this.config.dimension || 3; return texts.map(() => Array(dim).fill(0.1)); } diff --git a/packages/seekdb/tests/unit/utils.test.ts b/packages/seekdb/tests/unit/utils.test.ts new file mode 100644 index 0000000..2d72783 --- /dev/null +++ b/packages/seekdb/tests/unit/utils.test.ts @@ -0,0 +1,740 @@ +/** + * Unit tests for utility functions + * Tests normalizeValue, extractDistance, extractDimension, extractEmbeddingField, extractStringValue, etc. + */ + +import { describe, test, expect } from "vitest"; +import { + normalizeValue, + normalizeRow, + normalizeRows, + extractDistance, + extractDimension, + extractEmbeddingField, + extractStringValue, + extractColumnValue, + toArray, + normalizeEmbeddings, + validateRecordSetLengthConsistency, + validateIDs, + serializeMetadata, + deserializeMetadata, + escapeSqlString, + vectorToSqlString, + CollectionNames, + CollectionFieldNames, + TABLE_NAME_COLUMNS, + extractTableNamesFromResult, +} from "../../src/utils.js"; +import { SeekdbValueError } from "../../src/errors.js"; + +describe("Utility Functions", () => { + describe("normalizeValue", () => { + test("handles null and undefined", () => { + expect(normalizeValue(null)).toBe(null); + expect(normalizeValue(undefined)).toBe(null); + }); + + test("handles standard types (number, boolean)", () => { + expect(normalizeValue(123)).toBe(123); + expect(normalizeValue(true)).toBe(true); + expect(normalizeValue(false)).toBe(false); + expect(normalizeValue(0)).toBe(0); + expect(normalizeValue(-1)).toBe(-1); + }); + + test("handles object with VARCHAR wrapper", () => { + expect(normalizeValue({ VARCHAR: "test" })).toBe("test"); + expect(normalizeValue({ varchar: "test" })).toBe("test"); + expect(normalizeValue({ VARCHAR: "123" })).toBe("123"); + }); + + test("handles object with MEDIUMTEXT wrapper", () => { + expect(normalizeValue({ MEDIUMTEXT: "test" })).toBe("test"); + expect(normalizeValue({ mediumtext: "test" })).toBe("test"); + }); + + test("handles object with TEXT wrapper", () => { + expect(normalizeValue({ TEXT: "test" })).toBe("test"); + expect(normalizeValue({ text: "test" })).toBe("test"); + }); + + test("handles object with LONGTEXT wrapper", () => { + expect(normalizeValue({ LONGTEXT: "test" })).toBe("test"); + expect(normalizeValue({ longtext: "test" })).toBe("test"); + }); + + test("handles JSON string with VARCHAR wrapper", () => { + expect(normalizeValue('{"VARCHAR":"test"}')).toBe("test"); + // Note: lowercase "varchar" in JSON string may not be parsed correctly + // The function checks for uppercase keys first + expect(normalizeValue('{"VARCHAR":"123"}')).toBe("123"); + }); + + test("handles JSON string with MEDIUMTEXT wrapper", () => { + expect(normalizeValue('{"MEDIUMTEXT":"test"}')).toBe("test"); + // Note: lowercase "mediumtext" in JSON string may not be parsed correctly + // The function checks for uppercase keys first + }); + + test("handles JSON string with nested JSON in VARCHAR", () => { + const nested = '{"VARCHAR":"{\\"key\\":\\"value\\"}"}'; + const result = normalizeValue(nested); + expect(result).toBe('{"key":"value"}'); + }); + + test("handles JSON string with control characters", () => { + const withControl = '{"VARCHAR":"test\u0000value"}'; + const result = normalizeValue(withControl); + // Control characters should be removed during JSON parse + expect(result).toBe("testvalue"); + }); + + test("handles invalid JSON string gracefully", () => { + const invalid = '{"VARCHAR":"test"'; // Missing closing brace + const result = normalizeValue(invalid); + // Should fallback to regex extraction or return original + expect(result).toBeDefined(); + // Should try regex fallback + expect(typeof result).toBe("string"); + }); + + test("handles string without type wrapper", () => { + expect(normalizeValue("plain string")).toBe("plain string"); + expect(normalizeValue('{"key":"value"}')).toBe('{"key":"value"}'); + expect(normalizeValue("")).toBe(""); + }); + + test("handles array values", () => { + const arr = [1, 2, 3]; + expect(normalizeValue(arr)).toBe(arr); + expect(normalizeValue([])).toEqual([]); + }); + + test("handles object without type keys", () => { + const obj = { key: "value" }; + expect(normalizeValue(obj)).toBe(obj); + expect(normalizeValue({})).toEqual({}); + }); + + test("handles empty string in VARCHAR wrapper", () => { + // Empty string in object wrapper - the function uses || operator + // obj.VARCHAR || obj.MEDIUMTEXT returns undefined for empty string (falsy) + // So extracted is undefined, and the function returns the original object + const result = normalizeValue({ VARCHAR: "" }); + // The function returns the object as-is when extraction fails (empty string is falsy) + expect(result).toEqual({ VARCHAR: "" }); + + // For JSON string, similar issue - empty string is falsy in || expression + // So it falls back to regex or returns original + const jsonResult = normalizeValue('{"VARCHAR":""}'); + // May return original string or empty string depending on regex fallback + expect(typeof jsonResult === "string").toBe(true); + }); + }); + + describe("normalizeRow", () => { + test("normalizes all values in a row", () => { + const row = { + id: { VARCHAR: "123" }, + name: "test", + metadata: '{"VARCHAR":"{\\"key\\":\\"value\\"}"}', + }; + const normalized = normalizeRow(row); + expect(normalized.id).toBe("123"); + expect(normalized.name).toBe("test"); + expect(normalized.metadata).toBe('{"key":"value"}'); + }); + + test("handles null and undefined", () => { + expect(normalizeRow(null)).toBe(null); + expect(normalizeRow(undefined)).toBe(undefined); + expect(normalizeRow("string")).toBe("string"); + }); + + test("handles empty object", () => { + expect(normalizeRow({})).toEqual({}); + }); + }); + + describe("normalizeRows", () => { + test("normalizes array of rows", () => { + const rows = [ + { id: { VARCHAR: "1" }, name: "test1" }, + { id: { VARCHAR: "2" }, name: "test2" }, + ]; + const normalized = normalizeRows(rows); + expect(normalized[0].id).toBe("1"); + expect(normalized[1].id).toBe("2"); + }); + + test("handles empty array", () => { + expect(normalizeRows([])).toEqual([]); + }); + + test("handles non-array input", () => { + expect(normalizeRows(null as any)).toBe(null); + expect(normalizeRows(undefined as any)).toBe(undefined); + expect(normalizeRows("string" as any)).toBe("string"); + }); + }); + + describe("extractDistance", () => { + test("extracts l2 from standard CREATE TABLE format", () => { + const row = { + "Create Table": + "CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH(distance=l2, type=hnsw, lib=vsag))", + }; + expect(extractDistance(row)).toBe("l2"); + }); + + test("extracts cosine from standard CREATE TABLE format", () => { + const row = { + "Create Table": + "CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH(distance=cosine, type=hnsw, lib=vsag))", + }; + expect(extractDistance(row)).toBe("cosine"); + }); + + test("extracts inner_product from standard CREATE TABLE format", () => { + const row = { + "Create Table": + "CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH(distance=inner_product, type=hnsw, lib=vsag))", + }; + expect(extractDistance(row)).toBe("inner_product"); + }); + + test("extracts ip (alias for inner_product)", () => { + const row = { + "Create Table": + "CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH(distance=ip, type=hnsw, lib=vsag))", + }; + expect(extractDistance(row)).toBe("ip"); + }); + + test("handles CREATE TABLE with spaces in WITH clause", () => { + const row = { + "Create Table": + "CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH (distance=l2, type=hnsw, lib=vsag))", + }; + expect(extractDistance(row)).toBe("l2"); + }); + + test("handles CREATE TABLE with newlines", () => { + const row = { + "Create Table": `CREATE TABLE test ( + embedding VECTOR(3), + VECTOR INDEX idx_vec (embedding) WITH(distance=l2, type=hnsw, lib=vsag) + )`, + }; + expect(extractDistance(row)).toBe("l2"); + }); + + test("handles different column names (col_1, col_0)", () => { + const row = { + col_1: + "CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH(distance=l2, type=hnsw, lib=vsag))", + }; + expect(extractDistance(row)).toBe("l2"); + }); + + test("handles case-insensitive column names", () => { + const row = { + "create table": + "CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH(distance=l2, type=hnsw, lib=vsag))", + }; + expect(extractDistance(row)).toBe("l2"); + }); + + test("handles CREATE TABLE statement in any value (fallback)", () => { + const row = { + Table: "test_table", + SomeColumn: + "CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH(distance=l2, type=hnsw, lib=vsag))", + }; + expect(extractDistance(row)).toBe("l2"); + }); + + test("handles distance with quotes", () => { + const row = { + "Create Table": + 'CREATE TABLE test (embedding VECTOR(3), VECTOR INDEX idx_vec (embedding) WITH(distance="l2", type=hnsw, lib=vsag))', + }; + expect(extractDistance(row)).toBe("l2"); + }); + + test("returns null when distance not found", () => { + const row = { + "Create Table": "CREATE TABLE test (embedding VECTOR(3))", + }; + expect(extractDistance(row)).toBe(null); + }); + + test("returns null for invalid input", () => { + expect(extractDistance(null)).toBe(null); + expect(extractDistance(undefined)).toBe(null); + expect(extractDistance("string")).toBe(null); + expect(extractDistance(123)).toBe(null); + }); + + test("handles distance in fallback strategy (no CREATE TABLE found)", () => { + const row = { + someField: "some text with distance=l2 in it", + }; + expect(extractDistance(row)).toBe("l2"); + }); + }); + + describe("extractDimension", () => { + test("extracts dimension from VECTOR(128)", () => { + const field = { Type: "VECTOR(128)" }; + expect(extractDimension(field)).toBe(128); + }); + + test("extracts dimension from VECTOR(384)", () => { + const field = { Type: "VECTOR(384)" }; + expect(extractDimension(field)).toBe(384); + }); + + test("extracts dimension from VECTOR(3)", () => { + const field = { Type: "VECTOR(3)" }; + expect(extractDimension(field)).toBe(3); + }); + + test("handles different column names (type, TYPE)", () => { + const field = { type: "VECTOR(256)" }; + expect(extractDimension(field)).toBe(256); + }); + + test("handles VECTOR type in any value", () => { + const field = { Field: "embedding", SomeColumn: "VECTOR(128)" }; + expect(extractDimension(field)).toBe(128); + }); + + test("returns null for invalid format", () => { + const field = { Type: "VARCHAR(255)" }; + expect(extractDimension(field)).toBe(null); + }); + + test("returns null when VECTOR not found", () => { + const field = { Type: "STRING" }; + expect(extractDimension(field)).toBe(null); + }); + + test("returns null for null/undefined input", () => { + expect(extractDimension(null)).toBe(null); + expect(extractDimension(undefined)).toBe(null); + }); + }); + + describe("extractEmbeddingField", () => { + test("finds embedding field in schema by Field name", () => { + const schema = [ + { Field: "_id", Type: "VARBINARY(512)" }, + { Field: "document", Type: "STRING" }, + { Field: "embedding", Type: "VECTOR(128)" }, + { Field: "metadata", Type: "JSON" }, + ]; + const field = extractEmbeddingField(schema); + expect(field).toBeDefined(); + expect(field?.Field).toBe("embedding"); + expect(field?.Type).toBe("VECTOR(128)"); + }); + + test("handles different column names (field, FIELD)", () => { + const schema = [ + { field: "_id", type: "VARBINARY(512)" }, + { field: "embedding", type: "VECTOR(128)" }, + ]; + const field = extractEmbeddingField(schema); + expect(field).toBeDefined(); + expect(field?.field).toBe("embedding"); + }); + + test("finds embedding field by Type containing VECTOR (fallback)", () => { + const schema = [ + { Field: "_id", Type: "VARBINARY(512)" }, + { Field: "vec_field", Type: "VECTOR(128)" }, + ]; + const field = extractEmbeddingField(schema); + expect(field).toBeDefined(); + expect(field?.Type).toBe("VECTOR(128)"); + }); + + test("finds embedding field by searching all values (fallback)", () => { + const schema = [ + { Field: "_id", Type: "VARBINARY(512)" }, + { SomeColumn: "VECTOR(128)" }, + ]; + const field = extractEmbeddingField(schema); + expect(field).toBeDefined(); + }); + + test("returns null when no embedding field found", () => { + const schema = [ + { Field: "_id", Type: "VARBINARY(512)" }, + { Field: "document", Type: "STRING" }, + ]; + const result = extractEmbeddingField(schema); + // Function may return null or undefined when not found + expect(result === null || result === undefined).toBe(true); + }); + + test("returns null for empty schema", () => { + expect(extractEmbeddingField([])).toBe(null); + }); + + test("returns null for invalid input", () => { + expect(extractEmbeddingField(null as any)).toBe(null); + expect(extractEmbeddingField(undefined as any)).toBe(null); + }); + }); + + describe("extractStringValue", () => { + test("extracts value by exact column name match", () => { + const row = { Table: "test_table" }; + expect(extractStringValue(row, ["Table"])).toBe("test_table"); + }); + + test("extracts value by case-insensitive match", () => { + const row = { table: "test_table" }; + expect(extractStringValue(row, ["Table"])).toBe("test_table"); + }); + + test("extracts value by partial match", () => { + const row = { Tables_in_database: "test_table" }; + expect(extractStringValue(row, ["Table"])).toBe("test_table"); + }); + + test("tries multiple column names", () => { + const row = { col_1: "test_value" }; + expect(extractStringValue(row, ["Table", "col_1"])).toBe("test_value"); + }); + + test("returns null when not found", () => { + const row = { other: "value" }; + expect(extractStringValue(row, ["Table", "col_1"])).toBe(null); + }); + + test("handles normalized values (VARCHAR wrapper)", () => { + const row = { Table: { VARCHAR: "test_table" } }; + expect(extractStringValue(row, ["Table"])).toBe("test_table"); + }); + + test("handles null values", () => { + const row = { Table: null }; + expect(extractStringValue(row, ["Table"])).toBe(null); + }); + + test("handles undefined values", () => { + const row = { Table: undefined }; + expect(extractStringValue(row, ["Table"])).toBe(null); + }); + }); + + describe("extractColumnValue", () => { + test("extracts value with normalization", () => { + const row = { Table: { VARCHAR: "test_table" } }; + expect(extractColumnValue(row, ["Table"])).toBe("test_table"); + }); + + test("returns undefined when not found", () => { + const row = { other: "value" }; + expect(extractColumnValue(row, ["Table"])).toBe(undefined); + }); + + test("handles null/undefined input", () => { + expect(extractColumnValue(null, ["Table"])).toBe(undefined); + expect(extractColumnValue(undefined, ["Table"])).toBe(undefined); + }); + }); + + describe("toArray", () => { + test("converts single value to array", () => { + expect(toArray("test")).toEqual(["test"]); + expect(toArray(123)).toEqual([123]); + }); + + test("returns array as-is", () => { + expect(toArray(["test"])).toEqual(["test"]); + expect(toArray([1, 2, 3])).toEqual([1, 2, 3]); + }); + }); + + describe("normalizeEmbeddings", () => { + test("converts 1D array to 2D array", () => { + expect(normalizeEmbeddings([1, 2, 3])).toEqual([[1, 2, 3]]); + }); + + test("returns 2D array as-is", () => { + expect(normalizeEmbeddings([[1, 2, 3]])).toEqual([[1, 2, 3]]); + expect( + normalizeEmbeddings([ + [1, 2], + [3, 4], + ]) + ).toEqual([ + [1, 2], + [3, 4], + ]); + }); + + test("handles empty array", () => { + expect(normalizeEmbeddings([])).toEqual([]); + }); + }); + + describe("validateRecordSetLengthConsistency", () => { + test("passes when all lengths match", () => { + expect(() => { + validateRecordSetLengthConsistency({ + ids: ["1", "2"], + embeddings: [[1], [2]], + metadatas: [{}, {}], + documents: ["a", "b"], + }); + }).not.toThrow(); + }); + + test("passes when only one field is provided", () => { + expect(() => { + validateRecordSetLengthConsistency({ + ids: ["1", "2"], + }); + }).not.toThrow(); + }); + + test("throws when lengths don't match", () => { + expect(() => { + validateRecordSetLengthConsistency({ + ids: ["1", "2"], + embeddings: [[1]], + }); + }).toThrow(SeekdbValueError); + }); + }); + + describe("validateIDs", () => { + test("passes for unique IDs", () => { + expect(() => { + validateIDs(["1", "2", "3"]); + }).not.toThrow(); + }); + + test("throws for empty IDs", () => { + expect(() => { + validateIDs([]); + }).toThrow(SeekdbValueError); + }); + + test("throws for duplicate IDs", () => { + expect(() => { + validateIDs(["1", "2", "1"]); + }).toThrow(SeekdbValueError); + }); + }); + + describe("serializeMetadata", () => { + test("serializes metadata to JSON string", () => { + const metadata = { key: "value", num: 123 }; + const result = serializeMetadata(metadata); + expect(result).toBe('{"key":"value","num":123}'); + }); + }); + + describe("deserializeMetadata", () => { + test("deserializes JSON string to metadata", () => { + const json = '{"key":"value","num":123}'; + const result = deserializeMetadata(json); + expect(result).toEqual({ key: "value", num: 123 }); + }); + + test("throws for invalid JSON", () => { + expect(() => { + deserializeMetadata("invalid json"); + }).toThrow(SeekdbValueError); + }); + }); + + describe("escapeSqlString", () => { + test("escapes single quotes", () => { + expect(escapeSqlString("test'value")).toBe("test''value"); + expect(escapeSqlString("'test'")).toBe("''test''"); + }); + + test("handles string without quotes", () => { + expect(escapeSqlString("test")).toBe("test"); + }); + }); + + describe("vectorToSqlString", () => { + test("converts vector to JSON string", () => { + expect(vectorToSqlString([1, 2, 3])).toBe("[1,2,3]"); + expect(vectorToSqlString([1.5, 2.5, 3.5])).toBe("[1.5,2.5,3.5]"); + }); + + test("throws for non-array input", () => { + expect(() => { + vectorToSqlString("not array" as any); + }).toThrow(SeekdbValueError); + }); + + test("throws for NaN values", () => { + expect(() => { + vectorToSqlString([1, NaN, 3]); + }).toThrow(SeekdbValueError); + }); + + test("throws for Infinity values", () => { + expect(() => { + vectorToSqlString([1, Infinity, 3]); + }).toThrow(SeekdbValueError); + }); + }); + + describe("CollectionNames", () => { + test("generates table name", () => { + expect(CollectionNames.tableName("test")).toBe("c$v1$test"); + }); + }); + + describe("CollectionFieldNames", () => { + test("has correct field name constants", () => { + expect(CollectionFieldNames.ID).toBe("_id"); + expect(CollectionFieldNames.DOCUMENT).toBe("document"); + expect(CollectionFieldNames.METADATA).toBe("metadata"); + expect(CollectionFieldNames.EMBEDDING).toBe("embedding"); + }); + }); + + describe("TABLE_NAME_COLUMNS", () => { + test("contains expected column names", () => { + expect(TABLE_NAME_COLUMNS).toContain("Tables_in_database"); + expect(TABLE_NAME_COLUMNS).toContain("Table"); + expect(TABLE_NAME_COLUMNS).toContain("table"); + expect(TABLE_NAME_COLUMNS).toContain("TABLE"); + expect(TABLE_NAME_COLUMNS).toContain("Table_name"); + expect(TABLE_NAME_COLUMNS).toContain("table_name"); + expect(TABLE_NAME_COLUMNS).toContain("TABLE_NAME"); + expect(TABLE_NAME_COLUMNS.length).toBe(7); + }); + }); + + describe("extractTableNamesFromResult", () => { + test("extracts table names with prefix filter", () => { + const prefix = "c$v1$"; + const result = [ + { Tables_in_database: "c$v1$collection1" }, + { Tables_in_database: "c$v1$collection2" }, + { Tables_in_database: "other_table" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual(["c$v1$collection1", "c$v1$collection2"]); + }); + + test("handles different column name formats", () => { + const prefix = "c$v1$"; + const result = [ + { Table: "c$v1$collection1" }, + { TABLE_NAME: "c$v1$collection2" }, + { table_name: "c$v1$collection3" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual([ + "c$v1$collection1", + "c$v1$collection2", + "c$v1$collection3", + ]); + }); + + test("removes backticks from table names", () => { + const prefix = "c$v1$"; + const result = [ + { Tables_in_database: "`c$v1$collection1`" }, + { Tables_in_database: "c$v1$collection2" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual(["c$v1$collection1", "c$v1$collection2"]); + }); + + test("filters by prefix and removes duplicates", () => { + const prefix = "c$v1$"; + const result = [ + { Tables_in_database: "c$v1$collection1" }, + { Tables_in_database: "c$v1$collection1" }, // duplicate + { Tables_in_database: "c$v1$collection2" }, + { Tables_in_database: "other_table" }, // no prefix + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual(["c$v1$collection1", "c$v1$collection2"]); + }); + + test("handles information_schema format (TABLE_NAME column)", () => { + const prefix = "c$v1$"; + const result = [ + { TABLE_NAME: "c$v1$collection1" }, + { TABLE_NAME: "c$v1$collection2" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual(["c$v1$collection1", "c$v1$collection2"]); + }); + + test("falls back to first string value when column name not found", () => { + const prefix = "c$v1$"; + const result = [ + { unknown_column: "c$v1$collection1" }, + { other_field: "c$v1$collection2" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual(["c$v1$collection1", "c$v1$collection2"]); + }); + + test("handles empty result", () => { + const prefix = "c$v1$"; + const result: any[] = []; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual([]); + }); + + test("handles result with no matching prefix", () => { + const prefix = "c$v1$"; + const result = [ + { Tables_in_database: "other_table1" }, + { Tables_in_database: "other_table2" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual([]); + }); + + test("handles normalized values (VARCHAR wrapper)", () => { + const prefix = "c$v1$"; + const result = [ + { Tables_in_database: { VARCHAR: "c$v1$collection1" } }, + { Tables_in_database: "c$v1$collection2" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual(["c$v1$collection1", "c$v1$collection2"]); + }); + + test("handles null and undefined values", () => { + const prefix = "c$v1$"; + const result = [ + { Tables_in_database: null }, + { Tables_in_database: undefined }, + { Tables_in_database: "c$v1$collection1" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual(["c$v1$collection1"]); + }); + + test("handles empty string values", () => { + const prefix = "c$v1$"; + const result = [ + { Tables_in_database: "" }, + { Tables_in_database: "c$v1$collection1" }, + ]; + const tableNames = extractTableNamesFromResult(result, prefix); + expect(tableNames).toEqual(["c$v1$collection1"]); + }); + }); +}); diff --git a/packages/seekdb/tsconfig.json b/packages/seekdb/tsconfig.json index ad6542b..704aec9 100644 --- a/packages/seekdb/tsconfig.json +++ b/packages/seekdb/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "outDir": "dist", "composite": false, - "baseUrl": "." + "baseUrl": ".", + "paths": { + "@seekdb/js-bindings": ["../bindings/pkgs/js-bindings/seekdb.d.ts"] + } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"] diff --git a/packages/seekdb/tsup.config.ts b/packages/seekdb/tsup.config.ts index cc3fc62..ea5d47e 100644 --- a/packages/seekdb/tsup.config.ts +++ b/packages/seekdb/tsup.config.ts @@ -4,4 +4,9 @@ import baseConfig from "../../tsup.config.base"; export default defineConfig({ ...baseConfig, entry: ["src/index.ts"], + esbuildOptions(options, context) { + if (context.format === "cjs") { + options.define = { ...options.define, "import.meta.url": "__filename" }; + } + }, }); diff --git a/packages/seekdb/vitest.config.ts b/packages/seekdb/vitest.config.ts index 629bb02..a339dbd 100644 --- a/packages/seekdb/vitest.config.ts +++ b/packages/seekdb/vitest.config.ts @@ -14,6 +14,14 @@ export default defineConfig({ __dirname, "../embeddings/default-embed/index.ts" ), + "@seekdb/js-bindings": resolve( + __dirname, + "../bindings/pkgs/js-bindings/seekdb.js" + ), }, }, + optimizeDeps: { + // Force Vite to pre-bundle these dependencies + include: ["seekdb"], + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9529767..28a2565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,12 +17,18 @@ importers: dotenv: specifier: ^16.6.1 version: 16.6.1 + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^15.4.3 + version: 15.5.2 prettier: specifier: ^3.8.1 version: 3.8.1 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -46,11 +52,30 @@ importers: specifier: ^4.20.6 version: 4.21.0 + packages/bindings: + dependencies: + node-addon-api: + specifier: ^8.3.0 + version: 8.5.0 + devDependencies: + node-gyp: + specifier: ^10.3.1 + version: 10.3.1 + rimraf: + specifier: ^5.0.10 + version: 5.0.10 + + packages/bindings/pkgs/js-bindings: + dependencies: + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 + packages/embeddings/amazon-bedrock: dependencies: '@aws-sdk/client-bedrock-runtime': specifier: ^3.713.0 - version: 3.974.0 + version: 3.980.0 devDependencies: '@seekdb/common': specifier: workspace:* @@ -126,7 +151,7 @@ importers: dependencies: openai: specifier: ^6.9.1 - version: 6.16.0 + version: 6.17.0 devDependencies: '@seekdb/common': specifier: workspace:* @@ -139,7 +164,7 @@ importers: dependencies: openai: specifier: ^6.9.1 - version: 6.16.0 + version: 6.17.0 devDependencies: '@seekdb/common': specifier: workspace:* @@ -168,7 +193,7 @@ importers: dependencies: openai: specifier: ^6.9.1 - version: 6.16.0 + version: 6.17.0 devDependencies: '@seekdb/common': specifier: workspace:* @@ -184,7 +209,7 @@ importers: dependencies: openai: specifier: ^6.9.1 - version: 6.16.0 + version: 6.17.0 devDependencies: '@seekdb/common': specifier: workspace:* @@ -216,13 +241,11 @@ importers: version: 1.1.1(@seekdb/common@1.1.0)(seekdb@1.1.1) mysql2: specifier: ^3.11.5 - version: 3.16.1 - node-addon-api: - specifier: ^8.0.0 - version: 8.5.0 - node-gyp: - specifier: ^10.1.0 - version: 10.3.1 + version: 3.16.2 + optionalDependencies: + '@seekdb/js-bindings': + specifier: workspace:* + version: link:../bindings/pkgs/js-bindings packages: @@ -243,131 +266,127 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.974.0': - resolution: {integrity: sha512-x/xKuWcQ6tJaaJedkAtoLB5IqAn4Zblq3j5OS1PCNUjhKDVfiSV1ZOuD3/cAWRUDPROiZvNK1POGLkBtin3cxg==} + '@aws-sdk/client-bedrock-runtime@3.980.0': + resolution: {integrity: sha512-agRy8K543Q4WxCiup12JiSe4rO2gkw4wykaGXD+MEmzG2Nq4ODvKrNHT+XYCyTvk9ehJim/vpu+Stae3nEI0yw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-cognito-identity@3.974.0': - resolution: {integrity: sha512-V3anrX+U5XgEaLVwZOJIDSXMY0Tk/i/KpjEENB70ovIiTYb0uNnSHWUMlepxVxAIhcvN8egGTEq6vgULIYBJFA==} + '@aws-sdk/client-cognito-identity@3.980.0': + resolution: {integrity: sha512-nLgMW2drTzv+dTo3ORCcotQPcrUaTQ+xoaDTdSaUXdZO7zbbVyk7ysE5GDTnJdZWcUjHOSB8xfNQhOTTNVPhFw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sagemaker@3.974.0': - resolution: {integrity: sha512-maM1c/NlDKRQF7yNpHv73Hbp9ZIaLztZ9e2EZ6dCipfcKl3bOlWKSAuqxEnXCXgepT21Gh82NN9xc7zsPHuz6w==} + '@aws-sdk/client-sagemaker@3.980.0': + resolution: {integrity: sha512-ZRAv+ss7Ctjl5RO7biTngqoWZL3wCwn0kaVGiHlGwE/y+SnG4ziKiEsMSQsD6eRK7dCG6M218B1aISx1PRzQKQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sso@3.974.0': - resolution: {integrity: sha512-ci+GiM0c4ULo4D79UMcY06LcOLcfvUfiyt8PzNY0vbt5O8BfCPYf4QomwVgkNcLLCYmroO4ge2Yy1EsLUlcD6g==} + '@aws-sdk/client-sso@3.980.0': + resolution: {integrity: sha512-AhNXQaJ46C1I+lQ+6Kj+L24il5K9lqqIanJd8lMszPmP7bLnmX0wTKK0dxywcvrLdij3zhWttjAKEBNgLtS8/A==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.0': - resolution: {integrity: sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA==} + '@aws-sdk/core@3.973.5': + resolution: {integrity: sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-cognito-identity@3.972.1': - resolution: {integrity: sha512-/qAxlqkzW7UX9SUN+kFX1ZMHy0pFgwrfCjQYbVSmTOQvSprmzpNNj5RvVY/2WX78s5GMc6VQH/pwrOAxXbTiiA==} + '@aws-sdk/credential-provider-cognito-identity@3.972.3': + resolution: {integrity: sha512-dW/DqTk90XW7hIngqntAVtJJyrkS51wcLhGz39lOMe0TlSmZl+5R/UGnAZqNbXmWuJHLzxe+MLgagxH41aTsAQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.1': - resolution: {integrity: sha512-/etNHqnx96phy/SjI0HRC588o4vKH5F0xfkZ13yAATV7aNrb+5gYGNE6ePWafP+FuZ3HkULSSlJFj0AxgrAqYw==} + '@aws-sdk/credential-provider-env@3.972.3': + resolution: {integrity: sha512-OBYNY4xQPq7Rx+oOhtyuyO0AQvdJSpXRg7JuPNBJH4a1XXIzJQl4UHQTPKZKwfJXmYLpv4+OkcFen4LYmDPd3g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.1': - resolution: {integrity: sha512-AeopObGW5lpWbDRZ+t4EAtS7wdfSrHPLeFts7jaBzgIaCCD7TL7jAyAB9Y5bCLOPF+17+GL54djCCsjePljUAw==} + '@aws-sdk/credential-provider-http@3.972.5': + resolution: {integrity: sha512-GpvBgEmSZPvlDekd26Zi+XsI27Qz7y0utUx0g2fSTSiDzhnd1FSa1owuodxR0BcUKNL7U2cOVhhDxgZ4iSoPVg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.1': - resolution: {integrity: sha512-OdbJA3v+XlNDsrYzNPRUwr8l7gw1r/nR8l4r96MDzSBDU8WEo8T6C06SvwaXR8SpzsjO3sq5KMP86wXWg7Rj4g==} + '@aws-sdk/credential-provider-ini@3.972.3': + resolution: {integrity: sha512-rMQAIxstP7cLgYfsRGrGOlpyMl0l8JL2mcke3dsIPLWke05zKOFyR7yoJzWCsI/QiIxjRbxpvPiAeKEA6CoYkg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.1': - resolution: {integrity: sha512-CccqDGL6ZrF3/EFWZefvKW7QwwRdxlHUO8NVBKNVcNq6womrPDvqB6xc9icACtE0XB0a7PLoSTkAg8bQVkTO2w==} + '@aws-sdk/credential-provider-login@3.972.3': + resolution: {integrity: sha512-Gc3O91iVvA47kp2CLIXOwuo5ffo1cIpmmyIewcYjAcvurdFHQ8YdcBe1KHidnbbBO4/ZtywGBACsAX5vr3UdoA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.1': - resolution: {integrity: sha512-DwXPk9GfuU/xG9tmCyXFVkCr6X3W8ZCoL5Ptb0pbltEx1/LCcg7T+PBqDlPiiinNCD6ilIoMJDWsnJ8ikzZA7Q==} + '@aws-sdk/credential-provider-node@3.972.4': + resolution: {integrity: sha512-UwerdzosMSY7V5oIZm3NsMDZPv2aSVzSkZxYxIOWHBeKTZlUqW7XpHtJMZ4PZpJ+HMRhgP+MDGQx4THndgqJfQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.1': - resolution: {integrity: sha512-bi47Zigu3692SJwdBvo8y1dEwE6B61stCwCFnuRWJVTfiM84B+VTSCV661CSWJmIZzmcy7J5J3kWyxL02iHj0w==} + '@aws-sdk/credential-provider-process@3.972.3': + resolution: {integrity: sha512-xkSY7zjRqeVc6TXK2xr3z1bTLm0wD8cj3lAkproRGaO4Ku7dPlKy843YKnHrUOUzOnMezdZ4xtmFc0eKIDTo2w==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.1': - resolution: {integrity: sha512-dLZVNhM7wSgVUFsgVYgI5hb5Z/9PUkT46pk/SHrSmUqfx6YDvoV4YcPtaiRqviPpEGGiRtdQMEadyOKIRqulUQ==} + '@aws-sdk/credential-provider-sso@3.972.3': + resolution: {integrity: sha512-8Ww3F5Ngk8dZ6JPL/V5LhCU1BwMfQd3tLdoEuzaewX8FdnT633tPr+KTHySz9FK7fFPcz5qG3R5edVEhWQD4AA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.1': - resolution: {integrity: sha512-YMDeYgi0u687Ay0dAq/pFPKuijrlKTgsaB/UATbxCs/FzZfMiG4If5ksywHmmW7MiYUF8VVv+uou3TczvLrN4w==} + '@aws-sdk/credential-provider-web-identity@3.972.3': + resolution: {integrity: sha512-62VufdcH5rRfiRKZRcf1wVbbt/1jAntMj1+J0qAd+r5pQRg2t0/P9/Rz16B1o5/0Se9lVL506LRjrhIJAhYBfA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-providers@3.974.0': - resolution: {integrity: sha512-yVaoEcji7H0aXffvf5c50tqnXtaqR8n4DHsaH/hoNIbPplfYqdqLRtg106Jc7ir84YIy0SwTmyttwnzEAtGJsg==} + '@aws-sdk/credential-providers@3.980.0': + resolution: {integrity: sha512-xkuzICw1nu+MTEKNqkrNcNAEn8PYY08VMZk5jYSRenmdUfOch+vp1BZ3AGkD/8FxsJQwfo5ncpcHy4bMkNjBUA==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.1': - resolution: {integrity: sha512-sbPqSY+BjhHDTRUhCEvCY3lNL76FcPxiTuYesbSV0ZBfPT1JONjkAT8U6DIAy9C0ynlEuPfdVngMAOFDxP0kcQ==} + '@aws-sdk/eventstream-handler-node@3.972.3': + resolution: {integrity: sha512-uQbkXcfEj4+TrxTmZkSwsYRE9nujx9b6WeLoQkDsldzEpcQhtKIz/RHSB4lWe7xzDMfGCLUkwmSJjetGVcrhCw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.1': - resolution: {integrity: sha512-40iO9eYwycHmyZ5MnBRlQy35P7Aug3FRVAfrU8Lp88Ti66amJynvzgAuM+JaE/4LUTfFWigLfmdIp/d8CX625g==} + '@aws-sdk/middleware-eventstream@3.972.3': + resolution: {integrity: sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.1': - resolution: {integrity: sha512-/R82lXLPmZ9JaUGSUdKtBp2k/5xQxvBT3zZWyKiBOhyulFotlfvdlrO8TnqstBimsl4lYEYySDL+W6ldFh6ALg==} + '@aws-sdk/middleware-host-header@3.972.3': + resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.1': - resolution: {integrity: sha512-JGgFl6cHg9G2FHu4lyFIzmFN8KESBiRr84gLC3Aeni0Gt1nKm+KxWLBuha/RPcXxJygGXCcMM4AykkIwxor8RA==} + '@aws-sdk/middleware-logger@3.972.3': + resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.1': - resolution: {integrity: sha512-taGzNRe8vPHjnliqXIHp9kBgIemLE/xCaRTMH1NH0cncHeaPcjxtnCroAAM9aOlPuKvBe2CpZESyvM1+D8oI7Q==} + '@aws-sdk/middleware-recursion-detection@3.972.3': + resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.1': - resolution: {integrity: sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA==} + '@aws-sdk/middleware-user-agent@3.972.5': + resolution: {integrity: sha512-TVZQ6PWPwQbahUI8V+Er+gS41ctIawcI/uMNmQtQ7RMcg3JYn6gyKAFKUb3HFYx2OjYlx1u11sETSwwEUxVHTg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.1': - resolution: {integrity: sha512-zej/2+u6KCEHUipHW/4KXwj+4PTJkrORwR4KHNHE8ATdzLf7hwu7HsPK+TyQXZSftH+VqYkFmTbyF9OPxOpwmw==} + '@aws-sdk/middleware-websocket@3.972.3': + resolution: {integrity: sha512-/BjMbtOM9lsgdNgRZWUL5oCV6Ocfx1vcK/C5xO5/t/gCk6IwR9JFWMilbk6K6Buq5F84/lkngqcCKU2SRkAmOg==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.974.0': - resolution: {integrity: sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/region-config-resolver@3.972.1': - resolution: {integrity: sha512-voIY8RORpxLAEgEkYaTFnkaIuRwVBEc+RjVZYcSSllPV+ZEKAacai6kNhJeE3D70Le+JCfvRb52tng/AVHY+jQ==} + '@aws-sdk/nested-clients@3.980.0': + resolution: {integrity: sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.974.0': - resolution: {integrity: sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ==} + '@aws-sdk/region-config-resolver@3.972.3': + resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.972.0': - resolution: {integrity: sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==} + '@aws-sdk/token-providers@3.980.0': + resolution: {integrity: sha512-1nFileg1wAgDmieRoj9dOawgr2hhlh7xdvcH57b1NnqfPaVlcqVJyPc6k3TLDUFPY69eEwNxdGue/0wIz58vjA==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.0': - resolution: {integrity: sha512-jYIdB7a7jhRTvyb378nsjyvJh1Si+zVduJ6urMNGpz8RjkmHZ+9vM2H07XaIB2Cfq0GhJRZYOfUCH8uqQhqBkQ==} + '@aws-sdk/types@3.973.1': + resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.972.0': - resolution: {integrity: sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==} + '@aws-sdk/util-endpoints@3.980.0': + resolution: {integrity: sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-format-url@3.972.1': - resolution: {integrity: sha512-8wJ4/XOLU/RIYBHsXsIOTR04bNmalC8F2YPMyf3oL8YC750M3Rv5WGywW0Fo07HCv770KXJOzVq03Gyl68moFg==} + '@aws-sdk/util-format-url@3.972.3': + resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.3': - resolution: {integrity: sha512-FNUqAjlKAGA7GM05kywE99q8wiPHPZqrzhq3wXRga6PRD6A0kzT85Pb0AzYBVTBRpSrKyyr6M92Y6bnSBVp2BA==} + '@aws-sdk/util-locate-window@3.965.4': + resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.1': - resolution: {integrity: sha512-IgF55NFmJX8d9Wql9M0nEpk2eYbuD8G4781FN4/fFgwTXBn86DvlZJuRWDCMcMqZymnBVX7HW9r+3r9ylqfW0w==} + '@aws-sdk/util-user-agent-browser@3.972.3': + resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} - '@aws-sdk/util-user-agent-node@3.972.1': - resolution: {integrity: sha512-oIs4JFcADzoZ0c915R83XvK2HltWupxNsXUIuZse2rgk7b97zTpkxaqXiH0h9ylh31qtgo/t8hp4tIqcsMrEbQ==} + '@aws-sdk/util-user-agent-node@3.972.3': + resolution: {integrity: sha512-gqG+02/lXQtO0j3US6EVnxtwwoXQC5l2qkhLCrqUrqdtcQxV7FDMbm9wLjKqoronSHyELGTjbFKK/xV5q1bZNA==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -375,8 +394,8 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.1': - resolution: {integrity: sha512-6zZGlPOqn7Xb+25MAXGb1JhgvaC5HjZj6GzszuVrnEgbhvzBRFGKYemuHBV4bho+dtqeYKPgaZUv7/e80hIGNg==} + '@aws-sdk/xml-builder@3.972.2': + resolution: {integrity: sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.3': @@ -757,8 +776,8 @@ packages: engines: {node: '>=6'} hasBin: true - '@huggingface/jinja@0.5.3': - resolution: {integrity: sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA==} + '@huggingface/jinja@0.5.5': + resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==} engines: {node: '>=18'} '@huggingface/transformers@3.8.1': @@ -1010,141 +1029,141 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - '@rollup/rollup-android-arm-eabi@4.57.0': - resolution: {integrity: sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==} + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.57.0': - resolution: {integrity: sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==} + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.57.0': - resolution: {integrity: sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==} + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.57.0': - resolution: {integrity: sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==} + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.57.0': - resolution: {integrity: sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==} + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.57.0': - resolution: {integrity: sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==} + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.57.0': - resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.57.0': - resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.57.0': - resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.57.0': - resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.57.0': - resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.57.0': - resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.57.0': - resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.57.0': - resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.57.0': - resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.57.0': - resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.57.0': - resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.57.0': - resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.57.0': - resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.57.0': - resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.57.0': - resolution: {integrity: sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==} + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.57.0': - resolution: {integrity: sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==} + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.57.0': - resolution: {integrity: sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==} + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.57.0': - resolution: {integrity: sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==} + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.57.0': - resolution: {integrity: sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==} + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} cpu: [x64] os: [win32] @@ -1165,8 +1184,8 @@ packages: resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.21.1': - resolution: {integrity: sha512-NUH8R4O6FkN8HKMojzbGg/5pNjsfTjlMmeFclyPfPaXXUrbr5TzhWgbf7t92wfrpCHRgpjyz7ffASIS3wX28aA==} + '@smithy/core@3.22.0': + resolution: {integrity: sha512-6vjCHD6vaY8KubeNw2Fg3EK0KLGQYdldG4fYgQmA0xSW0dJ8G2xFhSOdrlUakWVoP5JuWHtFODg3PNd/DN3FDA==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.2.8': @@ -1217,12 +1236,12 @@ packages: resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.11': - resolution: {integrity: sha512-/WqsrycweGGfb9sSzME4CrsuayjJF6BueBmkKlcbeU5q18OhxRrvvKlmfw3tpDsK5ilx2XUJvoukwxHB0nHs/Q==} + '@smithy/middleware-endpoint@4.4.12': + resolution: {integrity: sha512-9JMKHVJtW9RysTNjcBZQHDwB0p3iTP6B1IfQV4m+uCevkVd/VuLgwfqk5cnI4RHcp4cPwoIvxQqN4B1sxeHo8Q==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.27': - resolution: {integrity: sha512-xFUYCGRVsfgiN5EjsJJSzih9+yjStgMTCLANPlf0LVQkPDYCe0hz97qbdTZosFOiYlGBlHYityGRxrQ/hxhfVQ==} + '@smithy/middleware-retry@4.4.29': + resolution: {integrity: sha512-bmTn75a4tmKRkC5w61yYQLb3DmxNzB8qSVu9SbTYqW6GAL0WXO2bDZuMAn/GJSbOdHEdjZvWxe+9Kk015bw6Cg==} engines: {node: '>=18.0.0'} '@smithy/middleware-serde@4.2.9': @@ -1269,8 +1288,8 @@ packages: resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.10.12': - resolution: {integrity: sha512-VKO/HKoQ5OrSHW6AJUmEnUKeXI1/5LfCwO9cwyao7CmLvGnZeM1i36Lyful3LK1XU7HwTVieTqO1y2C/6t3qtA==} + '@smithy/smithy-client@4.11.1': + resolution: {integrity: sha512-SERgNg5Z1U+jfR6/2xPYjSEHY1t3pyTHC/Ma3YQl6qWtmiL42bvNId3W/oMUWIwu7ekL2FMPdqAmwbQegM7HeQ==} engines: {node: '>=18.0.0'} '@smithy/types@4.12.0': @@ -1305,12 +1324,12 @@ packages: resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.26': - resolution: {integrity: sha512-vva0dzYUTgn7DdE0uaha10uEdAgmdLnNFowKFjpMm6p2R0XDk5FHPX3CBJLzWQkQXuEprsb0hGz9YwbicNWhjw==} + '@smithy/util-defaults-mode-browser@4.3.28': + resolution: {integrity: sha512-/9zcatsCao9h6g18p/9vH9NIi5PSqhCkxQ/tb7pMgRFnqYp9XUOyOlGPDMHzr8n5ih6yYgwJEY2MLEobUgi47w==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.29': - resolution: {integrity: sha512-c6D7IUBsZt/aNnTBHMTf+OVh+h/JcxUUgfTcIJaWRe6zhOum1X+pNKSZtZ+7fbOn5I99XVFtmrnXKv8yHHErTQ==} + '@smithy/util-defaults-mode-node@4.2.31': + resolution: {integrity: sha512-JTvoApUXA5kbpceI2vuqQzRjeTbLpx1eoa5R/YEZbTgtxvIB7AQZxFJ0SEyfCpgPCyVV9IT7we+ytSeIB3CyWA==} engines: {node: '>=18.0.0'} '@smithy/util-endpoints@3.2.8': @@ -1420,6 +1439,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1436,6 +1459,10 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} + ansi-escapes@7.2.0: + resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1535,6 +1562,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -1562,6 +1593,14 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1577,10 +1616,17 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1661,6 +1707,9 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1681,6 +1730,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -1736,10 +1789,17 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1846,6 +1906,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1854,8 +1918,12 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-tsconfig@4.13.1: + resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -1941,6 +2009,15 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1979,6 +2056,14 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1997,6 +2082,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -2052,6 +2141,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lint-staged@15.5.2: + resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@8.3.3: + resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} + engines: {node: '>=18.0.0'} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2069,6 +2167,10 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + long@1.1.5: resolution: {integrity: sha512-TU6nAF5SdasnTr28c7e74P4Crbn9o3/zwo1pM22Wvg2i2vlZ4Eelxwu4QT7j21z0sDBlJDEnEZjXTZg2J8WJrg==} engines: {node: '>=0.6'} @@ -2101,6 +2203,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2117,6 +2222,14 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -2176,8 +2289,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mysql2@3.16.1: - resolution: {integrity: sha512-b75qsDB3ieYEzMsT1uRGsztM/sy6vWPY40uPZlVVl8eefAotFCoS7jaDB5DxDNtlW5kdVGd9jptSpkvujNxI2A==} + mysql2@3.16.2: + resolution: {integrity: sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==} engines: {node: '>= 8.0'} mz@2.7.0: @@ -2219,6 +2332,10 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2241,6 +2358,14 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + onnxruntime-common@1.21.0: resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==} @@ -2254,8 +2379,8 @@ packages: onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} - openai@6.16.0: - resolution: {integrity: sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==} + openai@6.17.0: + resolution: {integrity: sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -2307,6 +2432,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -2336,6 +2465,11 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -2442,6 +2576,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry-request@7.0.2: resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} engines: {node: '>=14'} @@ -2454,12 +2592,19 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rollup@4.57.0: - resolution: {integrity: sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==} + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2535,6 +2680,14 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -2584,6 +2737,10 @@ packages: stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2592,6 +2749,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2607,6 +2768,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} @@ -2622,8 +2787,8 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - tar@7.5.6: - resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==} + tar@7.5.7: + resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} teeny-request@9.0.0: @@ -2837,6 +3002,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2851,6 +3020,11 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -2868,7 +3042,7 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -2876,15 +3050,15 @@ snapshots: '@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.973.0 - '@aws-sdk/util-locate-window': 3.965.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -2893,31 +3067,31 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.974.0': + '@aws-sdk/client-bedrock-runtime@3.980.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.0 - '@aws-sdk/credential-provider-node': 3.972.1 - '@aws-sdk/eventstream-handler-node': 3.972.1 - '@aws-sdk/middleware-eventstream': 3.972.1 - '@aws-sdk/middleware-host-header': 3.972.1 - '@aws-sdk/middleware-logger': 3.972.1 - '@aws-sdk/middleware-recursion-detection': 3.972.1 - '@aws-sdk/middleware-user-agent': 3.972.1 - '@aws-sdk/middleware-websocket': 3.972.1 - '@aws-sdk/region-config-resolver': 3.972.1 - '@aws-sdk/token-providers': 3.974.0 - '@aws-sdk/types': 3.973.0 - '@aws-sdk/util-endpoints': 3.972.0 - '@aws-sdk/util-user-agent-browser': 3.972.1 - '@aws-sdk/util-user-agent-node': 3.972.1 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/credential-provider-node': 3.972.4 + '@aws-sdk/eventstream-handler-node': 3.972.3 + '@aws-sdk/middleware-eventstream': 3.972.3 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/middleware-websocket': 3.972.3 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/token-providers': 3.980.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.3 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.21.1 + '@smithy/core': 3.22.0 '@smithy/eventstream-serde-browser': 4.2.8 '@smithy/eventstream-serde-config-resolver': 4.3.8 '@smithy/eventstream-serde-node': 4.2.8 @@ -2925,21 +3099,21 @@ snapshots: '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.11 - '@smithy/middleware-retry': 4.4.27 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/node-http-handler': 4.4.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.26 - '@smithy/util-defaults-mode-node': 4.2.29 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -2949,42 +3123,42 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-cognito-identity@3.974.0': + '@aws-sdk/client-cognito-identity@3.980.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.0 - '@aws-sdk/credential-provider-node': 3.972.1 - '@aws-sdk/middleware-host-header': 3.972.1 - '@aws-sdk/middleware-logger': 3.972.1 - '@aws-sdk/middleware-recursion-detection': 3.972.1 - '@aws-sdk/middleware-user-agent': 3.972.1 - '@aws-sdk/region-config-resolver': 3.972.1 - '@aws-sdk/types': 3.973.0 - '@aws-sdk/util-endpoints': 3.972.0 - '@aws-sdk/util-user-agent-browser': 3.972.1 - '@aws-sdk/util-user-agent-node': 3.972.1 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/credential-provider-node': 3.972.4 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.3 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.21.1 + '@smithy/core': 3.22.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.11 - '@smithy/middleware-retry': 4.4.27 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/node-http-handler': 4.4.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.26 - '@smithy/util-defaults-mode-node': 4.2.29 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -2993,42 +3167,42 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sagemaker@3.974.0': + '@aws-sdk/client-sagemaker@3.980.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.0 - '@aws-sdk/credential-provider-node': 3.972.1 - '@aws-sdk/middleware-host-header': 3.972.1 - '@aws-sdk/middleware-logger': 3.972.1 - '@aws-sdk/middleware-recursion-detection': 3.972.1 - '@aws-sdk/middleware-user-agent': 3.972.1 - '@aws-sdk/region-config-resolver': 3.972.1 - '@aws-sdk/types': 3.973.0 - '@aws-sdk/util-endpoints': 3.972.0 - '@aws-sdk/util-user-agent-browser': 3.972.1 - '@aws-sdk/util-user-agent-node': 3.972.1 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/credential-provider-node': 3.972.4 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.3 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.21.1 + '@smithy/core': 3.22.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.11 - '@smithy/middleware-retry': 4.4.27 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/node-http-handler': 4.4.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.26 - '@smithy/util-defaults-mode-node': 4.2.29 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -3038,41 +3212,41 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso@3.974.0': + '@aws-sdk/client-sso@3.980.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.0 - '@aws-sdk/middleware-host-header': 3.972.1 - '@aws-sdk/middleware-logger': 3.972.1 - '@aws-sdk/middleware-recursion-detection': 3.972.1 - '@aws-sdk/middleware-user-agent': 3.972.1 - '@aws-sdk/region-config-resolver': 3.972.1 - '@aws-sdk/types': 3.973.0 - '@aws-sdk/util-endpoints': 3.972.0 - '@aws-sdk/util-user-agent-browser': 3.972.1 - '@aws-sdk/util-user-agent-node': 3.972.1 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.3 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.21.1 + '@smithy/core': 3.22.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.11 - '@smithy/middleware-retry': 4.4.27 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/node-http-handler': 4.4.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.26 - '@smithy/util-defaults-mode-node': 4.2.29 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -3081,64 +3255,64 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.973.0': + '@aws-sdk/core@3.973.5': dependencies: - '@aws-sdk/types': 3.973.0 - '@aws-sdk/xml-builder': 3.972.1 - '@smithy/core': 3.21.1 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/xml-builder': 3.972.2 + '@smithy/core': 3.22.0 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-middleware': 4.2.8 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-cognito-identity@3.972.1': + '@aws-sdk/credential-provider-cognito-identity@3.972.3': dependencies: - '@aws-sdk/client-cognito-identity': 3.974.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/client-cognito-identity': 3.980.0 + '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-env@3.972.1': + '@aws-sdk/credential-provider-env@3.972.3': dependencies: - '@aws-sdk/core': 3.973.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.1': + '@aws-sdk/credential-provider-http@3.972.5': dependencies: - '@aws-sdk/core': 3.973.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/types': 3.973.1 '@smithy/fetch-http-handler': 5.3.9 '@smithy/node-http-handler': 4.4.8 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/util-stream': 4.5.10 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.1': - dependencies: - '@aws-sdk/core': 3.973.0 - '@aws-sdk/credential-provider-env': 3.972.1 - '@aws-sdk/credential-provider-http': 3.972.1 - '@aws-sdk/credential-provider-login': 3.972.1 - '@aws-sdk/credential-provider-process': 3.972.1 - '@aws-sdk/credential-provider-sso': 3.972.1 - '@aws-sdk/credential-provider-web-identity': 3.972.1 - '@aws-sdk/nested-clients': 3.974.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/credential-provider-ini@3.972.3': + dependencies: + '@aws-sdk/core': 3.973.5 + '@aws-sdk/credential-provider-env': 3.972.3 + '@aws-sdk/credential-provider-http': 3.972.5 + '@aws-sdk/credential-provider-login': 3.972.3 + '@aws-sdk/credential-provider-process': 3.972.3 + '@aws-sdk/credential-provider-sso': 3.972.3 + '@aws-sdk/credential-provider-web-identity': 3.972.3 + '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/types': 3.973.1 '@smithy/credential-provider-imds': 4.2.8 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -3147,11 +3321,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.1': + '@aws-sdk/credential-provider-login@3.972.3': dependencies: - '@aws-sdk/core': 3.973.0 - '@aws-sdk/nested-clients': 3.974.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -3160,15 +3334,15 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.1': + '@aws-sdk/credential-provider-node@3.972.4': dependencies: - '@aws-sdk/credential-provider-env': 3.972.1 - '@aws-sdk/credential-provider-http': 3.972.1 - '@aws-sdk/credential-provider-ini': 3.972.1 - '@aws-sdk/credential-provider-process': 3.972.1 - '@aws-sdk/credential-provider-sso': 3.972.1 - '@aws-sdk/credential-provider-web-identity': 3.972.1 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/credential-provider-env': 3.972.3 + '@aws-sdk/credential-provider-http': 3.972.5 + '@aws-sdk/credential-provider-ini': 3.972.3 + '@aws-sdk/credential-provider-process': 3.972.3 + '@aws-sdk/credential-provider-sso': 3.972.3 + '@aws-sdk/credential-provider-web-identity': 3.972.3 + '@aws-sdk/types': 3.973.1 '@smithy/credential-provider-imds': 4.2.8 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -3177,21 +3351,21 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.1': + '@aws-sdk/credential-provider-process@3.972.3': dependencies: - '@aws-sdk/core': 3.973.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.1': + '@aws-sdk/credential-provider-sso@3.972.3': dependencies: - '@aws-sdk/client-sso': 3.974.0 - '@aws-sdk/core': 3.973.0 - '@aws-sdk/token-providers': 3.974.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/client-sso': 3.980.0 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/token-providers': 3.980.0 + '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 @@ -3199,11 +3373,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.1': + '@aws-sdk/credential-provider-web-identity@3.972.3': dependencies: - '@aws-sdk/core': 3.973.0 - '@aws-sdk/nested-clients': 3.974.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 @@ -3211,23 +3385,23 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-providers@3.974.0': - dependencies: - '@aws-sdk/client-cognito-identity': 3.974.0 - '@aws-sdk/core': 3.973.0 - '@aws-sdk/credential-provider-cognito-identity': 3.972.1 - '@aws-sdk/credential-provider-env': 3.972.1 - '@aws-sdk/credential-provider-http': 3.972.1 - '@aws-sdk/credential-provider-ini': 3.972.1 - '@aws-sdk/credential-provider-login': 3.972.1 - '@aws-sdk/credential-provider-node': 3.972.1 - '@aws-sdk/credential-provider-process': 3.972.1 - '@aws-sdk/credential-provider-sso': 3.972.1 - '@aws-sdk/credential-provider-web-identity': 3.972.1 - '@aws-sdk/nested-clients': 3.974.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/credential-providers@3.980.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.980.0 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/credential-provider-cognito-identity': 3.972.3 + '@aws-sdk/credential-provider-env': 3.972.3 + '@aws-sdk/credential-provider-http': 3.972.5 + '@aws-sdk/credential-provider-ini': 3.972.3 + '@aws-sdk/credential-provider-login': 3.972.3 + '@aws-sdk/credential-provider-node': 3.972.4 + '@aws-sdk/credential-provider-process': 3.972.3 + '@aws-sdk/credential-provider-sso': 3.972.3 + '@aws-sdk/credential-provider-web-identity': 3.972.3 + '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/types': 3.973.1 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.21.1 + '@smithy/core': 3.22.0 '@smithy/credential-provider-imds': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 @@ -3236,55 +3410,55 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/eventstream-handler-node@3.972.1': + '@aws-sdk/eventstream-handler-node@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@smithy/eventstream-codec': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.1': + '@aws-sdk/middleware-eventstream@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.1': + '@aws-sdk/middleware-host-header@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.1': + '@aws-sdk/middleware-logger@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.1': + '@aws-sdk/middleware-recursion-detection@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@aws/lambda-invoke-store': 0.2.3 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.1': + '@aws-sdk/middleware-user-agent@3.972.5': dependencies: - '@aws-sdk/core': 3.973.0 - '@aws-sdk/types': 3.973.0 - '@aws-sdk/util-endpoints': 3.972.0 - '@smithy/core': 3.21.1 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.980.0 + '@smithy/core': 3.22.0 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.1': + '@aws-sdk/middleware-websocket@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 - '@aws-sdk/util-format-url': 3.972.1 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-format-url': 3.972.3 '@smithy/eventstream-codec': 4.2.8 '@smithy/eventstream-serde-browser': 4.2.8 '@smithy/fetch-http-handler': 5.3.9 @@ -3294,41 +3468,41 @@ snapshots: '@smithy/util-hex-encoding': 4.2.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.974.0': + '@aws-sdk/nested-clients@3.980.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.0 - '@aws-sdk/middleware-host-header': 3.972.1 - '@aws-sdk/middleware-logger': 3.972.1 - '@aws-sdk/middleware-recursion-detection': 3.972.1 - '@aws-sdk/middleware-user-agent': 3.972.1 - '@aws-sdk/region-config-resolver': 3.972.1 - '@aws-sdk/types': 3.973.0 - '@aws-sdk/util-endpoints': 3.972.0 - '@aws-sdk/util-user-agent-browser': 3.972.1 - '@aws-sdk/util-user-agent-node': 3.972.1 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.3 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.21.1 + '@smithy/core': 3.22.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.11 - '@smithy/middleware-retry': 4.4.27 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/node-http-handler': 4.4.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.26 - '@smithy/util-defaults-mode-node': 4.2.29 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -3337,19 +3511,19 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/region-config-resolver@3.972.1': + '@aws-sdk/region-config-resolver@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@smithy/config-resolver': 4.4.6 '@smithy/node-config-provider': 4.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.974.0': + '@aws-sdk/token-providers@3.980.0': dependencies: - '@aws-sdk/core': 3.973.0 - '@aws-sdk/nested-clients': 3.974.0 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/core': 3.973.5 + '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 @@ -3357,51 +3531,46 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.972.0': + '@aws-sdk/types@3.973.1': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/types@3.973.0': + '@aws-sdk/util-endpoints@3.980.0': dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.972.0': - dependencies: - '@aws-sdk/types': 3.972.0 + '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-endpoints': 3.2.8 tslib: 2.8.1 - '@aws-sdk/util-format-url@3.972.1': + '@aws-sdk/util-format-url@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@smithy/querystring-builder': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.3': + '@aws-sdk/util-locate-window@3.965.4': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.1': + '@aws-sdk/util-user-agent-browser@3.972.3': dependencies: - '@aws-sdk/types': 3.973.0 + '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 bowser: 2.13.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.972.1': + '@aws-sdk/util-user-agent-node@3.972.3': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.1 - '@aws-sdk/types': 3.973.0 + '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/types': 3.973.1 '@smithy/node-config-provider': 4.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.1': + '@aws-sdk/xml-builder@3.972.2': dependencies: '@smithy/types': 4.12.0 fast-xml-parser: 5.2.5 @@ -3734,11 +3903,11 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 - '@huggingface/jinja@0.5.3': {} + '@huggingface/jinja@0.5.5': {} '@huggingface/transformers@3.8.1': dependencies: - '@huggingface/jinja': 0.5.3 + '@huggingface/jinja': 0.5.5 onnxruntime-node: 1.21.0 onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4 sharp: 0.34.5 @@ -3943,79 +4112,79 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@rollup/rollup-android-arm-eabi@4.57.0': + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true - '@rollup/rollup-android-arm64@4.57.0': + '@rollup/rollup-android-arm64@4.57.1': optional: true - '@rollup/rollup-darwin-arm64@4.57.0': + '@rollup/rollup-darwin-arm64@4.57.1': optional: true - '@rollup/rollup-darwin-x64@4.57.0': + '@rollup/rollup-darwin-x64@4.57.1': optional: true - '@rollup/rollup-freebsd-arm64@4.57.0': + '@rollup/rollup-freebsd-arm64@4.57.1': optional: true - '@rollup/rollup-freebsd-x64@4.57.0': + '@rollup/rollup-freebsd-x64@4.57.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.57.0': + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.57.0': + '@rollup/rollup-linux-arm-musleabihf@4.57.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.57.0': + '@rollup/rollup-linux-arm64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.57.0': + '@rollup/rollup-linux-arm64-musl@4.57.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.57.0': + '@rollup/rollup-linux-loong64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-loong64-musl@4.57.0': + '@rollup/rollup-linux-loong64-musl@4.57.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.57.0': + '@rollup/rollup-linux-ppc64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-ppc64-musl@4.57.0': + '@rollup/rollup-linux-ppc64-musl@4.57.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.57.0': + '@rollup/rollup-linux-riscv64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.57.0': + '@rollup/rollup-linux-riscv64-musl@4.57.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.57.0': + '@rollup/rollup-linux-s390x-gnu@4.57.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.57.0': + '@rollup/rollup-linux-x64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-x64-musl@4.57.0': + '@rollup/rollup-linux-x64-musl@4.57.1': optional: true - '@rollup/rollup-openbsd-x64@4.57.0': + '@rollup/rollup-openbsd-x64@4.57.1': optional: true - '@rollup/rollup-openharmony-arm64@4.57.0': + '@rollup/rollup-openharmony-arm64@4.57.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.57.0': + '@rollup/rollup-win32-arm64-msvc@4.57.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.57.0': + '@rollup/rollup-win32-ia32-msvc@4.57.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.57.0': + '@rollup/rollup-win32-x64-gnu@4.57.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.57.0': + '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true '@seekdb/common@1.1.0': {} @@ -4040,7 +4209,7 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/core@3.21.1': + '@smithy/core@3.22.0': dependencies: '@smithy/middleware-serde': 4.2.9 '@smithy/protocol-http': 5.3.8 @@ -4125,9 +4294,9 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.11': + '@smithy/middleware-endpoint@4.4.12': dependencies: - '@smithy/core': 3.21.1 + '@smithy/core': 3.22.0 '@smithy/middleware-serde': 4.2.9 '@smithy/node-config-provider': 4.3.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -4136,12 +4305,12 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.27': + '@smithy/middleware-retry@4.4.29': dependencies: '@smithy/node-config-provider': 4.3.8 '@smithy/protocol-http': 5.3.8 '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -4215,10 +4384,10 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/smithy-client@4.10.12': + '@smithy/smithy-client@4.11.1': dependencies: - '@smithy/core': 3.21.1 - '@smithy/middleware-endpoint': 4.4.11 + '@smithy/core': 3.22.0 + '@smithy/middleware-endpoint': 4.4.12 '@smithy/middleware-stack': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 @@ -4263,20 +4432,20 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.26': + '@smithy/util-defaults-mode-browser@4.3.28': dependencies: '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.29': + '@smithy/util-defaults-mode-node@4.2.31': dependencies: '@smithy/config-resolver': 4.4.6 '@smithy/credential-provider-imds': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.10.12 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -4407,6 +4576,8 @@ snapshots: acorn@8.15.0: {} + adm-zip@0.5.16: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -4422,6 +4593,10 @@ snapshots: ansi-colors@4.1.3: {} + ansi-escapes@7.2.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -4517,6 +4692,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@5.6.2: {} + chardet@2.1.1: {} check-error@2.1.3: {} @@ -4533,6 +4710,15 @@ snapshots: clean-stack@2.2.0: {} + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -4542,8 +4728,8 @@ snapshots: cohere-ai@7.20.0: dependencies: '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sagemaker': 3.974.0 - '@aws-sdk/credential-providers': 3.974.0 + '@aws-sdk/client-sagemaker': 3.980.0 + '@aws-sdk/credential-providers': 3.980.0 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 convict: 6.2.4 @@ -4560,10 +4746,14 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + commander@13.1.0: {} + commander@4.1.1: {} confbox@0.1.8: {} @@ -4634,6 +4824,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -4654,6 +4846,8 @@ snapshots: env-paths@2.2.1: {} + environment@1.1.0: {} + err-code@2.0.3: {} es-define-property@1.0.1: {} @@ -4742,8 +4936,22 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -4785,7 +4993,7 @@ snapshots: dependencies: magic-string: 0.30.21 mlly: 1.8.0 - rollup: 4.57.0 + rollup: 4.57.1 flatbuffers@25.9.23: {} @@ -4866,6 +5074,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4884,7 +5094,9 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-tsconfig@4.13.0: + get-stream@8.0.1: {} + + get-tsconfig@4.13.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -5017,6 +5229,10 @@ snapshots: human-id@4.1.3: {} + human-signals@5.0.0: {} + + husky@9.1.7: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -5042,6 +5258,12 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.4.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -5054,6 +5276,8 @@ snapshots: is-stream@2.0.1: {} + is-stream@3.0.0: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -5108,6 +5332,30 @@ snapshots: lines-and-columns@1.2.4: {} + lint-staged@15.5.2: + dependencies: + chalk: 5.6.2 + commander: 13.1.0 + debug: 4.4.3 + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.3.3 + micromatch: 4.0.8 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.8.2 + transitivePeerDependencies: + - supports-color + + listr2@8.3.3: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + load-tsconfig@0.2.5: {} locate-path@5.0.0: @@ -5120,6 +5368,14 @@ snapshots: lodash.startcase@4.4.0: {} + log-update@6.1.0: + dependencies: + ansi-escapes: 7.2.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + long@1.1.5: {} long@5.3.2: {} @@ -5157,6 +5413,8 @@ snapshots: math-intrinsics@1.1.0: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -5170,6 +5428,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -5228,7 +5490,7 @@ snapshots: ms@2.1.3: {} - mysql2@3.16.1: + mysql2@3.16.2: dependencies: aws-ssl-profiles: 1.1.2 denque: 2.1.0 @@ -5281,6 +5543,10 @@ snapshots: dependencies: abbrev: 2.0.0 + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -5297,6 +5563,14 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + onnxruntime-common@1.21.0: {} onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: {} @@ -5305,7 +5579,7 @@ snapshots: dependencies: global-agent: 3.0.0 onnxruntime-common: 1.21.0 - tar: 7.5.6 + tar: 7.5.7 onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: dependencies: @@ -5316,7 +5590,7 @@ snapshots: platform: 1.3.6 protobufjs: 7.5.4 - openai@6.16.0: {} + openai@6.17.0: {} outdent@0.5.0: {} @@ -5350,6 +5624,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -5369,6 +5645,8 @@ snapshots: picomatch@4.0.3: {} + pidtree@0.6.0: {} + pify@4.0.1: {} pirates@4.0.7: {} @@ -5381,12 +5659,13 @@ snapshots: platform@1.3.6: {} - postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0): + postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.6 tsx: 4.21.0 + yaml: 2.8.2 postcss@8.5.6: dependencies: @@ -5467,6 +5746,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry-request@7.0.2(encoding@0.1.13): dependencies: '@types/request': 2.48.13 @@ -5480,6 +5764,12 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -5489,35 +5779,35 @@ snapshots: semver-compare: 1.0.0 sprintf-js: 1.1.3 - rollup@4.57.0: + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.0 - '@rollup/rollup-android-arm64': 4.57.0 - '@rollup/rollup-darwin-arm64': 4.57.0 - '@rollup/rollup-darwin-x64': 4.57.0 - '@rollup/rollup-freebsd-arm64': 4.57.0 - '@rollup/rollup-freebsd-x64': 4.57.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.0 - '@rollup/rollup-linux-arm-musleabihf': 4.57.0 - '@rollup/rollup-linux-arm64-gnu': 4.57.0 - '@rollup/rollup-linux-arm64-musl': 4.57.0 - '@rollup/rollup-linux-loong64-gnu': 4.57.0 - '@rollup/rollup-linux-loong64-musl': 4.57.0 - '@rollup/rollup-linux-ppc64-gnu': 4.57.0 - '@rollup/rollup-linux-ppc64-musl': 4.57.0 - '@rollup/rollup-linux-riscv64-gnu': 4.57.0 - '@rollup/rollup-linux-riscv64-musl': 4.57.0 - '@rollup/rollup-linux-s390x-gnu': 4.57.0 - '@rollup/rollup-linux-x64-gnu': 4.57.0 - '@rollup/rollup-linux-x64-musl': 4.57.0 - '@rollup/rollup-openbsd-x64': 4.57.0 - '@rollup/rollup-openharmony-arm64': 4.57.0 - '@rollup/rollup-win32-arm64-msvc': 4.57.0 - '@rollup/rollup-win32-ia32-msvc': 4.57.0 - '@rollup/rollup-win32-x64-gnu': 4.57.0 - '@rollup/rollup-win32-x64-msvc': 4.57.0 + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 run-parallel@1.2.0: @@ -5530,7 +5820,7 @@ snapshots: seekdb@1.1.1(@seekdb/default-embed@1.1.1): dependencies: - mysql2: 3.16.1 + mysql2: 3.16.2 node-addon-api: 8.5.0 node-gyp: 10.3.1 optionalDependencies: @@ -5619,6 +5909,16 @@ snapshots: slash@3.0.0: {} + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} socks-proxy-agent@8.0.5: @@ -5663,6 +5963,8 @@ snapshots: stream-shift@1.0.3: {} + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5675,6 +5977,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -5689,6 +5997,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@3.0.0: {} + strnum@2.1.2: {} stubs@3.0.0: {} @@ -5712,7 +6022,7 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - tar@7.5.6: + tar@7.5.7: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -5768,7 +6078,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.2) cac: 6.7.14 @@ -5779,9 +6089,9 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0) + postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) resolve-from: 5.0.0 - rollup: 4.57.0 + rollup: 4.57.1 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 0.3.2 @@ -5799,7 +6109,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.2 - get-tsconfig: 4.13.0 + get-tsconfig: 4.13.1 optionalDependencies: fsevents: 2.3.3 @@ -5849,7 +6159,7 @@ snapshots: dependencies: esbuild: 0.21.5 postcss: 8.5.6 - rollup: 4.57.0 + rollup: 4.57.1 optionalDependencies: '@types/node': 22.19.7 fsevents: 2.3.3 @@ -5935,6 +6245,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} y18n@5.0.8: {} @@ -5943,6 +6259,8 @@ snapshots: yallist@5.0.0: {} + yaml@2.8.2: {} + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ddba83c..87243d4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - "packages/*" - "packages/embeddings/*" + - "packages/bindings/pkgs/*" - "examples" diff --git a/vitest.config.base.ts b/vitest.config.base.ts index fa2abec..bdd8b02 100644 --- a/vitest.config.base.ts +++ b/vitest.config.base.ts @@ -22,8 +22,8 @@ export default { // set timeout testTimeout: 60000, - hookTimeout: 30000, - teardownTimeout: 10000, + hookTimeout: 60000, + teardownTimeout: 30000, // disable file parallelism, ensure stability fileParallelism: false, @@ -33,5 +33,15 @@ export default { // ensure normal exit even if tests fail bail: 0, + + // sequence tests to ensure proper cleanup between test files + sequence: { + concurrent: false, + shuffle: false, + }, + + // Use basic reporter to reduce duplicate output + // This prevents the verbose real-time progress updates that appear as duplicates + reporter: ["basic"], }, };