diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 34bfca4..9848ecc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,23 +5,16 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 14 groups: everything: patterns: - "*" - package-ecosystem: npm + target-branch: develop directory: "/" schedule: interval: "weekly" cooldown: - default-days: 15 - groups: - dev-dependencies: - dependency-type: "development" - update-types: - - "minor" - - "patch" - prod-dependencies: - dependency-type: "production" - update-types: - - "patch" + default-days: 14 diff --git a/.github/package.json b/.github/package.json index f93dbf4..fcb2ded 100644 --- a/.github/package.json +++ b/.github/package.json @@ -1,10 +1,12 @@ { "name": "@datastream/github-workflows", - "version": "0.5.0", + "version": "0.6.1", "private": true, + "type": "module", "engines": { - "node": ">=24.0" + "node": ">=24" }, + "engineStrict": true, "devDependencies": { "license-check-and-add": "4.0.5", "lockfile-lint": "5.0.0" diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index f295154..98e1bda 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -22,6 +22,7 @@ jobs: analysis: name: Scorecard analysis runs-on: ubuntu-latest + timeout-minutes: 15 permissions: # Needed to upload the results to code-scanning dashboard. security-events: write @@ -32,6 +33,11 @@ jobs: # actions: read steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: "Checkout code" uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2800946..cc24a6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,14 @@ env: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: build: name: Build - if: ${{ github.event.pull_request.merged && github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.event.pull_request.merged }} runs-on: ubuntu-latest permissions: @@ -24,8 +28,15 @@ jobs: id-token: write # actions/attest-build-provenance attestations: write # actions/attest-build-provenance steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -35,23 +46,27 @@ jobs: run: | tag=$(npm pkg get version | xargs) echo "tag=${tag}" >> "$GITHUB_ENV" - echo "prerelease=$([ ${tag##*-*} ] && echo false || echo true)" >> "$GITHUB_ENV" + echo "prerelease=$([ "${tag##*-*}" ] && echo false || echo true)" >> "$GITHUB_ENV" - name: Install dependencies run: | npm ci --ignore-scripts + - name: Verify dependency signatures + run: | + npm audit signatures - name: Build run: | npm run build --if-present - name: Pack id: pack run: | + mapfile -t args < <(npm query '.workspace:not([private])' | jq -r '.[] | "--workspace", .location') { echo "packages<> "$GITHUB_OUTPUT" - name: Build Attestations - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | **/*.tgz @@ -74,18 +89,23 @@ jobs: permissions: contents: write # softprops/action-gh-release steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # zizmor: ignore[cache-poisoning] no cache configured (no `cache:` input); reconsider if caching is added with: node-version: ${{ env.NODE_VERSION }} registry-url: https://registry.npmjs.org - name: Download artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.build.outputs.tag }} - name: Release # Replaces actions/create-release & actions/upload-release-asset (deprecated) - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 # zizmor: ignore[superfluous-actions] prefer maintained action over inline `gh release` script with: draft: true prerelease: ${{ needs.build.outputs.prerelease }} @@ -103,19 +123,39 @@ jobs: needs: release runs-on: ubuntu-latest + environment: npm-publish + permissions: contents: read id-token: write # npm publish + attestations: read # gh attestation verify steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: block + disable-telemetry: true + allowed-endpoints: > + registry.npmjs.org:443 + fulcio.sigstore.dev:443 + rekor.sigstore.dev:443 + tuf-repo-cdn.sigstore.dev:443 + github.com:443 + api.github.com:443 - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ env.NODE_VERSION }} registry-url: https://registry.npmjs.org - name: Download artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.release.outputs.tag }} + - name: Verify build provenance attestation + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + find . -name '*.tgz' -print -exec gh attestation verify {} --repo ${{ github.repository }} \; - name: npm publish (next) if: ${{ needs.release.outputs.prerelease == 'true' }} run: | diff --git a/.github/workflows/test-dast.yml b/.github/workflows/test-dast.yml index 79eb8a3..83e0ee9 100644 --- a/.github/workflows/test-dast.yml +++ b/.github/workflows/test-dast.yml @@ -3,6 +3,10 @@ name: Tests (dast) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -13,10 +17,18 @@ jobs: fuzz: name: Tests (fuzz) runs-on: ubuntu-latest + timeout-minutes: 30 steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.github/workflows/test-dco.yml b/.github/workflows/test-dco.yml index 024218f..2bd395d 100644 --- a/.github/workflows/test-dco.yml +++ b/.github/workflows/test-dco.yml @@ -3,6 +3,10 @@ name: Tests (dco) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -10,6 +14,12 @@ jobs: dco: name: Tests (dco) runs-on: ubuntu-latest + timeout-minutes: 10 steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Check for Developer Certificate of Origin (DCO) compliance uses: KineticCafe/actions-dco@1da04282bbf757dab7d92a5c8535dbfb8113da5c # v3.1.0 diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml index 5936ecd..9a99215 100644 --- a/.github/workflows/test-lint.yml +++ b/.github/workflows/test-lint.yml @@ -3,6 +3,10 @@ name: Tests (lint) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -13,10 +17,18 @@ jobs: lint: name: Tests (lint) runs-on: ubuntu-latest + timeout-minutes: 10 steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.github/workflows/test-mutation.yml b/.github/workflows/test-mutation.yml new file mode 100644 index 0000000..8820f2b --- /dev/null +++ b/.github/workflows/test-mutation.yml @@ -0,0 +1,74 @@ +name: Tests (mutation) + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 24.x + +permissions: + contents: read + +jobs: + setup: + name: List packages + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + packages: ${{ steps.list.outputs.packages }} + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: List packages + id: list + run: | + echo "packages=$(find packages -maxdepth 1 -mindepth 1 -type d -printf '%f\n' | jq -R -s -c 'split("\n") | map(select(length > 0))')" >> "$GITHUB_OUTPUT" + + mutation: + name: Tests (mutation) + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.setup.outputs.packages) }} + + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + cache: npm + - name: Install dependencies + run: | + npm ci --ignore-scripts + - name: Build + run: | + npm run build --if-present + - name: Mutation tests + env: + MUTATE_PACKAGE: ${{ matrix.package }} + run: | + npx stryker run diff --git a/.github/workflows/test-perf.yml b/.github/workflows/test-perf.yml index dec5f29..8ba2ab9 100644 --- a/.github/workflows/test-perf.yml +++ b/.github/workflows/test-perf.yml @@ -3,6 +3,10 @@ name: Tests (perf) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -13,10 +17,18 @@ jobs: unit: name: Tests (perf) runs-on: ubuntu-latest + timeout-minutes: 30 steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.github/workflows/test-sast.yml b/.github/workflows/test-sast.yml index 9570721..142b61b 100644 --- a/.github/workflows/test-sast.yml +++ b/.github/workflows/test-sast.yml @@ -6,6 +6,10 @@ on: - cron: "43 3 * * 5" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -17,28 +21,46 @@ jobs: trivy-vuln: name: "Trivy: SCA" runs-on: ubuntu-latest + timeout-minutes: 15 if: (github.actor != 'dependabot[bot]') steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Trivy uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: + # Scan the whole repo (matching the local `test:sast:trivy` script, + # `trivy fs ... .`) instead of only package-lock.json, and do not + # ignore unfixed advisories, so green CI implies green local SCA. scanners: vuln scan-type: fs - scan-ref: package-lock.json + scan-ref: . hide-progress: true - ignore-unfixed: true exit-code: 1 format: github trivy-license: name: "Trivy: Licensing" runs-on: ubuntu-latest + timeout-minutes: 15 if: (github.actor != 'dependabot[bot]') steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -65,7 +87,7 @@ jobs: # steps: # - name: Checkout repository - # uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # - name: OSS Review Toolkit (ORT) # uses: oss-review-toolkit/ort-ci-github-action@1805edcf1f4f55f35ae6e4d2d9795ccfb29b6021 # 1.1.0 # with: @@ -76,10 +98,16 @@ jobs: license: name: "License headers" runs-on: ubuntu-latest + timeout-minutes: 10 if: (github.actor != 'dependabot[bot]') steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} @@ -98,11 +126,19 @@ jobs: lockfile: name: "lockfile-lint: SAST package-lock.json" runs-on: ubuntu-latest + timeout-minutes: 10 if: (github.actor != 'dependabot[bot]') steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -120,6 +156,7 @@ jobs: codeql: name: "CodeQL: SAST" runs-on: ubuntu-latest + timeout-minutes: 30 permissions: actions: read contents: read @@ -128,23 +165,30 @@ jobs: strategy: fail-fast: false matrix: - language: [javascript] + language: [javascript, actions] steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{ matrix.language }}" @@ -152,14 +196,102 @@ jobs: semgrep: name: "semgrep: SAST" runs-on: ubuntu-latest + timeout-minutes: 15 container: # https://hub.docker.com/r/semgrep/semgrep/tags - image: semgrep/semgrep:1.111.0 # v1.111.0 + image: semgrep/semgrep@sha256:3e6e5065d9e68abffddffdb536a8db2d79a8fa92dc424daa48d3a4b7d9bc65d0 # v1.111.0 if: (github.actor != 'dependabot[bot]') steps: + # harden-runner skipped: this job runs in a container; harden-runner patches the host runner. - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: semgrep run: semgrep ci + + # https://github.com/raven-actions/actionlint + actionlint: + name: "actionlint: GitHub Actions lint" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: actionlint + uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2 + + # https://github.com/zizmorcore/zizmor-action + zizmor: + name: "zizmor: GitHub Actions SAST" + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + security-events: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: zizmor + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + + # https://github.com/trufflesecurity/trufflehog + trufflehog: + name: 'TruffleHog: secrets' + runs-on: ubuntu-latest + timeout-minutes: 10 + if: (github.actor != 'dependabot[bot]') + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + - name: TruffleHog + uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2 + with: + extra_args: --only-verified --results=verified,unknown + + # https://github.com/gitleaks/gitleaks-action + gitleaks: + name: 'gitleaks: secrets' + runs-on: ubuntu-latest + timeout-minutes: 10 + if: (github.actor != 'dependabot[bot]') + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + - name: gitleaks + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-types.yml b/.github/workflows/test-types.yml index 1bdf817..af6527c 100644 --- a/.github/workflows/test-types.yml +++ b/.github/workflows/test-types.yml @@ -3,6 +3,10 @@ name: Tests (types) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -13,10 +17,18 @@ jobs: types: name: Tests (types) runs-on: ubuntu-latest + timeout-minutes: 10 steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index f891f9b..03619ab 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -3,6 +3,10 @@ name: Tests (unit) on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_VERSION: 24.x @@ -13,10 +17,18 @@ jobs: unit: name: Tests (unit) runs-on: ubuntu-latest + timeout-minutes: 15 steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.github/workflows/website-cloudflare-pages.yml b/.github/workflows/website-cloudflare-pages.yml index 525ba43..bd27563 100644 --- a/.github/workflows/website-cloudflare-pages.yml +++ b/.github/workflows/website-cloudflare-pages.yml @@ -19,14 +19,22 @@ jobs: website: name: Publish website runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + disable-telemetry: true - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # zizmor: ignore[cache-poisoning] no cache configured (no `cache:` input); reconsider if caching is added with: node-version: ${{ env.NODE_VERSION }} registry-url: "https://registry.npmjs.org" diff --git a/.gitignore b/.gitignore index 45fbb2c..2ed9060 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# Stryker mutation testing output +reports +.stryker-tmp + # Runtime data pids *.pid @@ -116,5 +120,11 @@ dist *.iml .nova +# Agents +## /graphify hook install +.husky/post-checkout +.husky/post-commit +graphify-out + # OS .DS_Store diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..0655caa --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,26 @@ +[extend] +useDefault = true + +[[allowlists]] +description = "Ignore build artifacts and generated output" +paths = [ + '''\.svelte-kit/''', + '''node_modules/''', + '''.*\.node\.mjs$''', + '''.*\.web\.mjs$''', + '''.*\.map$''', +] + +[[allowlists]] +description = "CHACHA20-POLY1305 is an algorithm name, not a secret" +targetRules = ["generic-api-key"] +regexes = [ + '''CHACHA20-POLY1305''', +] + +[[allowlists]] +description = "Hello, World! base64 documentation example" +targetRules = ["generic-api-key"] +regexes = [ + '''SGVsbG8sIFdvcmxkIQ==''', +] diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..5cbd922 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,2 @@ +#!/bin/sh +npm run git:pre-push diff --git a/.license.config.json b/.license.config.json index ee3e249..cad9a73 100644 --- a/.license.config.json +++ b/.license.config.json @@ -14,9 +14,11 @@ "bin/*", "CNAME", "commitlint.config.cjs", + "stryker.config.*", "docs/**/*", "LICENSE", ".license.template", + ".gitleaks.toml", "**/.gitignore", "**/*.fuzz.js", "**/*.perf.js", diff --git a/README.md b/README.md index 0b41514..5ed6cf5 100644 --- a/README.md +++ b/README.md @@ -21,25 +21,38 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

-- [`@datastream/core`](#core) +- [`@datastream/core`](packages/core) - pipeline - pipejoin + - result - streamToArray + - streamToObject - streamToString + - streamToBuffer - isReadable - isWritable - makeOptions - createReadableStream + - createPassThroughStream - createTransformStream - createWritableStream + - resolveLazy + - shallowClone + - deepClone + - shallowEqual + - deepEqual + - timeout + - createReadableStreamFromString (Node only) + - createReadableStreamFromArrayBuffer (Node only) + - backpressureGauge (Node only) ## Streams @@ -50,39 +63,108 @@ ### Basics -- [`@datastream/string`](#string) +- [`@datastream/string`](packages/string) - stringReadableStream [Readable] - stringLengthStream [PassThrough] - - stringOutputStream [PassThrough] -- [`@datastream/object`](#object) + - stringCountStream [PassThrough] + - stringMinimumFirstChunkSize [Transform] + - stringMinimumChunkSize [Transform] + - stringSkipConsecutiveDuplicates [Transform] + - stringReplaceStream [Transform] + - stringSplitStream [Transform] +- [`@datastream/object`](packages/object) - objectReadableStream [Readable] - objectCountStream [PassThrough] - objectBatchStream [Transform] - - objectOutputStream [PassThrough] + - objectPivotLongToWideStream [Transform] + - objectPivotWideToLongStream [Transform] + - objectKeyValueStream [Transform] + - objectKeyValuesStream [Transform] + - objectKeyJoinStream [Transform] + - objectKeyMapStream [Transform] + - objectValueMapStream [Transform] + - objectPickStream [Transform] + - objectOmitStream [Transform] + - objectFromEntriesStream [Transform] + - objectToEntriesStream [Transform] + - objectSkipConsecutiveDuplicatesStream [Transform] ### Common -- [`@datastream/fetch`](#fetch) +- [`@datastream/file`](packages/file) + - fileReadStream [Readable] + - fileWriteStream [Writable] +- [`@datastream/fetch`](packages/fetch) - fetchResponseStream [Readable] -- [`@datastream/charset[/{detect,decode,encode}]`](#charset) +- [`@datastream/base64`](packages/base64) + - base64EncodeStream [Transform] + - base64DecodeStream [Transform] +- [`@datastream/charset[/{detect,decode,encode}]`](packages/charset) - charsetDetectStream [PassThrough] - charsetDecodeStream [Transform] - charsetEncodeStream [Transform] -- [`@datastream/compression[/{gzip,deflate}]`](#compression) - - gzipCompressionStream [Transform] - - gzipDecompressionStream [Transform] - - deflateCompressionStream [Transform] - - deflateDecompressionStream [Transform] -- [`@datastream/digest`](#digest) +- [`@datastream/compress[/{gzip,deflate,brotli,zstd}]`](packages/compress) + - gzipCompressStream [Transform] + - gzipDecompressStream [Transform] + - deflateCompressStream [Transform] + - deflateDecompressStream [Transform] + - brotliCompressStream [Transform] + - brotliDecompressStream [Transform] + - zstdCompressStream [Transform] + - zstdDecompressStream [Transform] +- [`@datastream/digest`](packages/digest) - digestStream [PassThrough] ### Advanced -- [`@datastream/csv[/{parse,format}]`](#csv) +- [`@datastream/csv[/{parse,format}]`](packages/csv) - csvParseStream [Transform] - csvFormatStream [Transform] -- [`@datastream/validate`](#validate) +- [`@datastream/json`](packages/json) + - jsonParseStream [Transform] + - jsonFormatStream [Transform] + - ndjsonParseStream [Transform] + - ndjsonFormatStream [Transform] +- [`@datastream/encrypt`](packages/encrypt) + - encryptStream [Transform] + - decryptStream [Transform] + - generateEncryptionKey +- [`@datastream/validate`](packages/validate) - validateStream [Transform] +- [`@datastream/arrow`](packages/arrow) + - arrowDetectSchemaStream [PassThrough] + - arrowBatchFromArrayStream [Transform] + - arrowBatchFromObjectStream [Transform] + - arrowToArrayStream [Transform] + - arrowToObjectStream [Transform] +- [`@datastream/duckdb`](packages/duckdb) + - duckdbConnect (connection helper) + - duckdbAppenderStream [Writable] + - duckdbArrowInsertStream [Writable] +- [`@datastream/indexeddb`](packages/indexeddb) + - indexedDBReadStream [Readable] + - indexedDBWriteStream [Writable] +- [`@datastream/ipfs`](packages/ipfs) + - ipfsGetStream [Readable] + - ipfsAddStream [Writable] +- [`@datastream/protobuf`](packages/protobuf) + - protobufEncodeStream [Transform] + - protobufDecodeStream [Transform] + - protobufLengthPrefixFrameStream [Transform] + - protobufLengthPrefixUnframeStream [Transform] +- [`@datastream/schema-registry`](packages/schema-registry) + - confluentFrameStream [Transform] + - confluentUnframeStream [Transform] + - glueFrameStream [Transform] + - glueUnframeStream [Transform] +- [`@datastream/kafka`](packages/kafka) + - kafkaConnect (producer/consumer connection helper) + - kafkaConsumeStream [Readable] + - kafkaProduceStream [Writable] +- [`@datastream/aws/msk-iam`](packages/aws) + - awsMskIamMechanism (kafkajs OAUTHBEARER SASL config) +- [`@datastream/aws/glue-schema-registry`](packages/aws) + - awsGlueSchemaRegistryResolver (cached GetSchemaVersion lookup) ## Setup @@ -95,7 +177,7 @@ npm install @datastream/core @datastream/{module} ```mermaid stateDiagram-v2 - [*] --> fileRead*: path + [*] --> fileRead: path [*] --> fetchResponse: URL [*] --> sqlCopyTo*: SQL [*] --> stringReadable: string @@ -127,7 +209,7 @@ stateDiagram-v2 encryption --> writable: buffer state readable { - fileRead* + fileRead fetchResponse sqlCopyTo* createReadable @@ -140,20 +222,19 @@ stateDiagram-v2 } state decompression { - brotliDeompression - gzipDeompression - deflateDeompression - zstdDecompression* - protobufDecompression* + brotliDecompress + gzipDecompress + deflateDecompress + zstdDecompress } state decryption { - decryption* + decrypt } state parse { csvParse - jsonParse* + jsonParse xmlParse* } @@ -163,12 +244,12 @@ stateDiagram-v2 state passThroughString { stringLength - stringOutput + stringCount } state passThroughObject { objectCount - objectOutput + objectBatch } state transform { @@ -181,26 +262,25 @@ stateDiagram-v2 state format { csvFormat - jsonFormat* + jsonFormat xmlFormat* } state compression { - brotliCompression - gzipCompression - deflateCompression - zstdCompression* - protobufCompression* + brotliCompress + gzipCompress + deflateCompress + zstdCompress } state encryption { - encryption* + encrypt } state writable { - fileWrite* + fileWrite fetchRequest* - sqlCopyFrom* + sqlCopyFrom awsS3Put awsDynamoDBPut awsDynamoDBDelete @@ -275,9 +355,9 @@ Read a CSV file, validate the structure, pivot data, then save compressed. Upload file with brotli compression? -### WebWorker: Decompress protobuf compressed JSON requests +### WebWorker: Decode protobuf-encoded requests into JSON -Fetch protobuf file, decompress, parse JSON +Fetch a protobuf-encoded file, decode the protobuf framing, then parse JSON ### streams @@ -294,4 +374,4 @@ Fetch protobuf file, decompress, parse JSON ## License -Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell) and [contributors](https://github.com/middyjs/middy/graphs/contributors). +Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell) and [contributors](https://github.com/willfarrell/datastream/graphs/contributors). diff --git a/bin/esbuild b/bin/esbuild index 139376e..ea60330 100755 --- a/bin/esbuild +++ b/bin/esbuild @@ -1,4 +1,7 @@ #!/usr/bin/env sh +set -e + +find packages -type f \( -name '*.node.mjs' -o -name '*.web.mjs' -o -name '*.node.mjs.map' -o -name '*.web.mjs.map' \) -delete for package in packages/*; do modules=$(jq -r '.exports | keys[] as $k | "\($k)"' ${package}/package.json) diff --git a/biome.json b/biome.json index d4959a8..f406365 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -15,7 +15,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "complexity": { "noBannedTypes": "off" }, @@ -50,6 +50,23 @@ "linter": { "enabled": false } + }, + "linter": { + "rules": { + "a11y": { + "useSemanticElements": "off" + } + } + } + }, + { + "includes": ["**/*.svg"], + "linter": { + "rules": { + "a11y": { + "noSvgWithoutTitle": "off" + } + } } } ] diff --git a/package-lock.json b/package-lock.json index d7f9b88..3546e59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@datastream/monorepo", - "version": "0.5.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@datastream/monorepo", - "version": "0.5.0", + "version": "0.6.1", "license": "MIT", "workspaces": [ "packages/*", @@ -14,15 +14,16 @@ ".github" ], "devDependencies": { - "@biomejs/biome": "^2.4.16", - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2", - "@types/node": "^25.9.2", - "esbuild": "^0.28.1", + "@biomejs/biome": "^2.0.0", + "@commitlint/cli": "^21.0.0", + "@commitlint/config-conventional": "^21.0.0", + "@stryker-mutator/core": "^9.0.0", + "@types/node": "^25.0.0", + "esbuild": "^0.28.0", "fast-check": "^4.8.0", "husky": "^9.0.0", "tinybench": "^6.0.2", - "tstyche": "^7.2.1" + "tstyche": "^7.2.0" }, "engines": { "node": ">=24" @@ -30,13 +31,13 @@ }, ".github": { "name": "@datastream/github-workflows", - "version": "0.5.0", + "version": "0.6.1", "devDependencies": { "license-check-and-add": "4.0.5", "lockfile-lint": "5.0.0" }, "engines": { - "node": ">=24.0" + "node": ">=24" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -54,306 +55,157 @@ "@types/json-schema": "^7.0.15" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-4.0.0.tgz", + "integrity": "sha512-MHGJyjE7TX9aaqXj7zk2ppnFUOhaDs5sP+HtNS0evOxn72c+5njUmyJmpGd7TfyoDznZlHMmdo/xGUdu2NIjNQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/util": "^5.2.0", + "@aws-crypto/util": "^4.0.0", "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" + "tslib": "^1.11.1" } }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } + "license": "0BSD" }, "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-4.0.0.tgz", + "integrity": "sha512-2EnmPy2gsFZ6m8bwUQN4jq+IyXV3quHAcwPOS6ZA3k+geujiqI8aRokO2kFJe+idJ/P3v4qWI186rVMo0+zLDQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } + "license": "0BSD" }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.16.tgz", + "integrity": "sha512-EKnvkXSmz3IpA99tCNuI+dLFXyZyClSm8zns9sB/elvkU+MTuomAs6toJMPMBf98/fICG/urXDkzGz0/c3yyAQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "node_modules/@aws-sdk/client-cloudwatch-logs": { + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1085.0.tgz", + "integrity": "sha512-jNMt5zMsnJ6X0fZJKKNU5/qTQ98OIsUSFLqDh+FTXQTiEI3NTq95gAdsaAwXVWO9hTaT5gY7fbwF5Ally69bwg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/checksums": { - "version": "3.1000.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.8.tgz", - "integrity": "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA==", + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1081.0.tgz", + "integrity": "sha512-ipDgvZ3Hy8kaFrkwj62yuoghKkZi5WrWTiJnaqGc/ntbuAuijIkRWhzSb8d1MGEE+qb+vFFTrWVFwGSqt76TCA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-node": "^3.972.64", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1065.0.tgz", - "integrity": "sha512-wQZtnsU5ogs6H4nViafC8nESKZgvP9TsnRCQw+hAnzbLTx+djrZ+GBNs4XizcGBrHjdokJDPVMowLvM7MUSq9w==", + "node_modules/@aws-sdk/client-dynamodb": { + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1085.0.tgz", + "integrity": "sha512-EpEFvJm+YjrCeDeXTQcbvk2zviLhKxM7exEiuAXXs4QT6h8HemeMY3gxF0S4gScjzhu3VdDXjLLnlJczIj/49w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/dynamodb-codec": "^3.973.31", + "@aws-sdk/middleware-endpoint-discovery": "^3.972.23", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-dynamodb": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1065.0.tgz", - "integrity": "sha512-Qf2iffOSoJzShRnP/RKcNEcRIxTryzlf81pQhxXeEgWUUnBAuPLH4qBD3I1mlghdNcrspZM6jGT4CUNt6UtUbw==", + "node_modules/@aws-sdk/client-dynamodb-streams": { + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb-streams/-/client-dynamodb-streams-3.1085.0.tgz", + "integrity": "sha512-sZ3SeswHhzwZzg+EN9fd3P4sAcm3O5lgrVYm8zWu+pGQXZnPpHf6uxjLNgK18l9fUQMPP1FtR50ZBGEfi2yvGw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/dynamodb-codec": "^3.973.20", - "@aws-sdk/middleware-endpoint-discovery": "^3.972.18", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-dynamodb-streams": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb-streams/-/client-dynamodb-streams-3.1065.0.tgz", - "integrity": "sha512-SzzOdnKP5zrDZCCdqgYTRSOypGTisA1NZUwnN7GDsAZjMuuMiRqdLCQML5HkoEPGLvPlQT1ZoJOD21F36e5qug==", + "node_modules/@aws-sdk/client-glue": { + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-glue/-/client-glue-3.1085.0.tgz", + "integrity": "sha512-D/PpbHZbEpaR6wH7Ss/BbqQwXMH5nPEfnb1wB+bgqWdYMHP7aLhwJNI81Lq/CwbiypZKTu+iX/ZdrHgK+kb7jA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -361,21 +213,19 @@ } }, "node_modules/@aws-sdk/client-kinesis": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-kinesis/-/client-kinesis-3.1065.0.tgz", - "integrity": "sha512-l1Spnj/Ooi8QtoN2HgAthht/2JAyAfJGWUZ9CDcgafClPeu9H1yqc2+q3BJNuqeQZMthKHxwYr10bPffsnFL5w==", + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kinesis/-/client-kinesis-3.1085.0.tgz", + "integrity": "sha512-Q+JQhmBMlndSST/ZBkgbBqwPX4WuaWWWvBbOZt5L9K0wvsh8y6jAI75qx5gxVz155zEyNIzbfgZSYmHf9Bzb8w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -383,21 +233,19 @@ } }, "node_modules/@aws-sdk/client-lambda": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1065.0.tgz", - "integrity": "sha512-PLMy74D4cyVU1LekBc+/0x8vN/Jjazbfa9fT0eprnPtU07bXST+NXIGRA3N7pZ4gzTTNJ62R5P+x/p+yZ0tHhQ==", + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1085.0.tgz", + "integrity": "sha512-Ms41fQmgYK/iF89KLoLVxSwPGtCwIYGyH+1Ovm6l7jiubxDmUfzU7keLzZHOymfxfDz5bBcqfwID65fuMKEnYQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -405,25 +253,22 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1065.0.tgz", - "integrity": "sha512-KtCpg2vihUjhUpqpsZIcB6Dm1hv9lRn5hWwU6hXtYfSQionFD/dB/gTwgyn9AHX6eGiCFqHfjIZwETz5Cz4RWA==", + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1085.0.tgz", + "integrity": "sha512-O0xe8sR50AYkwxlvRRsV0qytEO2dtXQTQ1CF3YBBdE5xtVkbu27H0vGa1mjQi1/+fbYM80AWEIPai5jZmXyubw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/middleware-flexible-checksums": "^3.974.29", - "@aws-sdk/middleware-sdk-s3": "^3.972.50", - "@aws-sdk/signature-v4-multi-region": "^3.996.33", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/checksums": "^3.1000.16", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/middleware-sdk-s3": "^3.972.62", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -431,21 +276,19 @@ } }, "node_modules/@aws-sdk/client-sns": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.1065.0.tgz", - "integrity": "sha512-UpPCclPYk1zKD+RF1hH02309YNKvUKbPjfmvGQpXQF5czcgfVxnZp0/S31XMjhF8S7fO82Z69f/RYXTTnP1bRA==", + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.1085.0.tgz", + "integrity": "sha512-F9AOKvav/zv8uukLXSgqG/EQErB/UMEahadT47iWqAWa0FahvxcchnzthN8kECoBCTsUn16N/1+wSvGTgJ9hWQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -453,44 +296,41 @@ } }, "node_modules/@aws-sdk/client-sqs": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1065.0.tgz", - "integrity": "sha512-dZkXT/UmJq5vBDZK0tzxCAXgyVSBrXfABK1UGWJ2H3HFdPYdKHQ4P/OS8Y9qS08yPL7Jl5aNuRElcXXANi0RbA==", + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1085.0.tgz", + "integrity": "sha512-ND4HTISx/vwGhNmqgwaI/oQRVrTwqGerPJL0a6z0bRRme73fQ8RcciuVEDZT/klD5/J/uvw8g7cWlo+XjIMF/Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/middleware-sdk-sqs": "^3.972.30", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/middleware-sdk-sqs": "^3.972.35", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-ssm": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1065.0.tgz", - "integrity": "sha512-qHdmHVrx6gtUPwF9lnDd2cxkvUdbs8JOzn0esVTOXcwLxH+KKzrkMWrV5FHfiWh3Nsd121waRpKSfEX1Tg25VQ==", + "node_modules/@aws-sdk/client-sts": { + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1085.0.tgz", + "integrity": "sha512-ej/Fhb74+ZzmU5vUBQ+zZvrZ223dq5QxrmU6ufCKALvFz5u66z3+dR3jwtUUIOjxLeLXy4Y6YHy6+Z76JkhJAA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.54", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -498,18 +338,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", - "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "version": "3.975.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", + "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@aws-sdk/xml-builder": "^3.972.31", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.0", + "@aws-sdk/xml-builder": "^3.972.34", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.2", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -517,17 +357,34 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.56.tgz", + "integrity": "sha512-nedaZz6Wz2FGUBMsWhlvBPGIkTMfRuMJ99YDHJI4CaC31JS8WPyTavEAio74cfd5nGhi0A8XASB4fZA5VqTdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", - "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", + "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -535,18 +392,18 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", - "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", + "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -554,24 +411,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.56", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", - "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", + "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-env": "^3.972.49", - "@aws-sdk/credential-provider-http": "^3.972.51", - "@aws-sdk/credential-provider-login": "^3.972.55", - "@aws-sdk/credential-provider-process": "^3.972.49", - "@aws-sdk/credential-provider-sso": "^3.972.55", - "@aws-sdk/credential-provider-web-identity": "^3.972.55", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-login": "^3.972.63", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -579,17 +436,17 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", - "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", + "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -597,22 +454,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.58", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", - "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz", + "integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.49", - "@aws-sdk/credential-provider-http": "^3.972.51", - "@aws-sdk/credential-provider-ini": "^3.972.56", - "@aws-sdk/credential-provider-process": "^3.972.49", - "@aws-sdk/credential-provider-sso": "^3.972.55", - "@aws-sdk/credential-provider-web-identity": "^3.972.55", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-ini": "^3.973.1", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -620,16 +477,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", - "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", + "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -637,18 +494,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", - "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", + "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/token-providers": "3.1074.0", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/token-providers": "3.1083.0", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -656,17 +513,46 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", - "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", + "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1081.0.tgz", + "integrity": "sha512-UHWvxd1F5nfBXBRXtQaXWoNT8CYKXZovLQOyz6XZlgFTGc2mWzzPGspOfnh49jwvB2qzsdH046i9jnnmND+64w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/client-cognito-identity": "3.1081.0", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.54", + "@aws-sdk/credential-provider-env": "^3.972.55", + "@aws-sdk/credential-provider-http": "^3.972.57", + "@aws-sdk/credential-provider-ini": "^3.972.62", + "@aws-sdk/credential-provider-login": "^3.972.61", + "@aws-sdk/credential-provider-node": "^3.972.64", + "@aws-sdk/credential-provider-process": "^3.972.55", + "@aws-sdk/credential-provider-sso": "^3.972.61", + "@aws-sdk/credential-provider-web-identity": "^3.972.61", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -674,15 +560,15 @@ } }, "node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.23.tgz", - "integrity": "sha512-GFfrjNXw+QteWbum8jD22G3T0FD/XiJEGp6r1CoqndMc6pxyQFoQ8nqTuNn1rbqYwqv4iCTl7GYoB9H17REKzw==", + "version": "3.973.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.31.tgz", + "integrity": "sha512-R8hSxTSdx49/Vz4qVvIQXtx10ka9mVxkXHvT43AYTYF7bb/TyzD/7BXcGTrsGYeJ6PV0y8RlNLGn2HumjHbsxw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -690,9 +576,9 @@ } }, "node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.8.tgz", - "integrity": "sha512-bBmkG0Dnhfq0/T4Z0PpUr7HkncBVaWvvCbvafeaUM+yC9wa8GGjLJmonq0QL17REB9WivgGeYgWQ5A80Uw5UnQ==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.9.tgz", + "integrity": "sha512-LFvdgq8SriaskUcjpBMDE7J2c9RmuT5v3gU36/znV71EU5DKUis4FmGFjCMelKCCViFeVrQADBAlIiOYRhEx6Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -704,14 +590,14 @@ } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1065.0.tgz", - "integrity": "sha512-0eEVoAWuvob9BkiKc/jtFPaOQYR7hbW6xioTe3b1U1WpwyVLKO6KROdm1zpMC7k5EOpVDhdFXkqZvFSCJuc4Uw==", + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1085.0.tgz", + "integrity": "sha512-S+yCGIMxQ5Zs2A5ZDdYfXwOxu5kKjBaCQVOxp6+mnwCQYXUNkBD/aKCnW1eniMTavzz3YidhD5eTFpDlcUv2gQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", @@ -721,34 +607,20 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.1065.0" + "@aws-sdk/client-s3": "^3.1085.0" } }, "node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.19.tgz", - "integrity": "sha512-FMgyzUq3Jh+ONRYxryBRNdBd+FUX8PwRl07ccQknNdoms6KCeAEusCkl6whqpDrPQ6OH0ddeSifKyqYSs2DLIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.8", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.33.tgz", - "integrity": "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g==", + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.23.tgz", + "integrity": "sha512-FoEkpD2A8haYWNT4wp8zW1x0Hc+NyxcnKJ2Hf4jX8EI5R4ybo1LRI3ql7iuFX1MTyklZx9hum9dPkq6R46pQMw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/checksums": "^3.1000.8", + "@aws-sdk/endpoint-cache": "^3.972.9", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -756,17 +628,17 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.54", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.54.tgz", - "integrity": "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ==", + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.62.tgz", + "integrity": "sha512-k8JJwYXVYlOOjWnPZDThQS1xDFJgi5Dokt73qFlDtrZAbdcint5aIdjB9XgJAAQVP5OoqcefQmh1FYXiPpvsvw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/signature-v4-multi-region": "^3.996.35", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -774,15 +646,15 @@ } }, "node_modules/@aws-sdk/middleware-sdk-sqs": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.31.tgz", - "integrity": "sha512-56ifsBmK9bLn5EE/t6c0nmjOB1BO8cJDLkA1VOlsN1GR85ROqnaCwVDspqcwsLaBDgPlwyYNedoDIoT3t6Ho1A==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.35.tgz", + "integrity": "sha512-J1u7OABwjjBB7sUJiET05dPVAfjFeR6vfM5//DInKuPzc7PhPVNEviC7RqbsIsBdZi8xICVnHemFrTuvGmhySA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -790,21 +662,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", - "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", + "version": "3.997.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", + "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/signature-v4-multi-region": "^3.996.35", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -812,15 +682,15 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", - "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "version": "3.996.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", + "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.0", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -828,17 +698,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1074.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", - "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", + "version": "3.1083.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", + "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -846,40 +716,51 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", - "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", + "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.33.tgz", + "integrity": "sha512-yKdN0GW+dVRANmFf+kJH6EnZlvl4ynaYRaUKC8SVRWrAgNe/KjA0FIrH9zyjy38kI5q3GZx6JJ7cwsJTFvRDHw==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "^3.975.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-utf8-browser": { + "version": "3.259.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", + "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.3.1" + } + }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", - "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", + "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -887,9 +768,9 @@ } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -897,13 +778,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -911,7786 +792,10095 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@biomejs/biome": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", - "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "MIT OR Apache-2.0", + "license": "ISC", "bin": { - "biome": "bin/biome" + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.16", - "@biomejs/cli-darwin-x64": "2.4.16", - "@biomejs/cli-linux-arm64": "2.4.16", - "@biomejs/cli-linux-arm64-musl": "2.4.16", - "@biomejs/cli-linux-x64": "2.4.16", - "@biomejs/cli-linux-x64-musl": "2.4.16", - "@biomejs/cli-win32-arm64": "2.4.16", - "@biomejs/cli-win32-x64": "2.4.16" + "node": ">=6.9.0" } }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", - "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", - "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", - "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", - "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "dev": true, - "libc": [ - "musl" - ], - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", - "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", - "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, - "libc": [ - "musl" - ], - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", - "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", - "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, "engines": { - "node": ">=22.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260609.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260609.1.tgz", - "integrity": "sha512-AK8tYLQm+8BqQMzjZ55ZfuhfIm1eCkj+Ykxz6kWXojdACwjjU03MrwdM9fBDdgzU3upXOs4e1scOFHySlfVQjA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=6.9.0" } }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260609.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260609.1.tgz", - "integrity": "sha512-4kKXfr7ZHU6xQ/R9ShdSuj1A1bEouoRcHzUWdjnuMPBlRsAAVanlxAVYISotFUulLEinayOpRFbhpsfwzrpSSw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, "engines": { - "node": ">=16" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260609.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260609.1.tgz", - "integrity": "sha512-T2Ebir2OPHAvvZ0HUh5mi1lN8q30sVi4lf7LIpc28AHoWtoOmJ0jA5AJK4IYJm1MKEbBldq+QsckaHOCQFmRpQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=16" + "node": ">=6.9.0" } }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260609.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260609.1.tgz", - "integrity": "sha512-INfcYoSsKqEIvPL69/3RkqYoP8WUR0VEN6loWN/3tekXLoJrVOj3E5NjIetsdS8MJN6zc3st/ae4bMuWRRzoDg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=6.9.0" } }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260609.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260609.1.tgz", - "integrity": "sha512-EWhfxKI1aqUr7S8xuGxgmRCumEzB8iSsCIz6oEqJN+3pZuW3EWiKDGFW4EY1BmwNINLW1eO5VMGYb8Fj6FVYxA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=6.9.0" } }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20260624.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260624.1.tgz", - "integrity": "sha512-o8i70Nycnm6KpPPfdjHek09IkG3hoIAv88d1pn90Djn2oXcQ35dYuzKYZUBd0eE2Tpsd5yz8L/a6FvI6CKFBQw==", + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, - "license": "MIT OR Apache-2.0" + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@commitlint/cli": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.2.tgz", - "integrity": "sha512-YMmfLbqBg+ZRvvmPhc+cilSQFrh/AgzVgCT1U/OifmUZEwPbvCtA8rN//YNaF9d5eoZphxVMGYtmwA2QgQORgg==", + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^21.0.1", - "@commitlint/lint": "^21.0.2", - "@commitlint/load": "^21.0.2", - "@commitlint/read": "^21.0.2", - "@commitlint/types": "^21.0.1", - "tinyexec": "^1.0.0", - "yargs": "^18.0.0" - }, - "bin": { - "commitlint": "cli.js" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" } }, - "node_modules/@commitlint/config-conventional": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.2.tgz", - "integrity": "sha512-P/ZRhryQmkj0Z0dY9FOoRwe3xkwJyyAdtXwt01NT2kuZttcG2CNYp1q5Ci3u+nDT2jcbJRw2kt13Czl1qKNPfg==", + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", - "conventional-changelog-conventionalcommits": "^9.2.0" + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.0.0" } }, - "node_modules/@commitlint/config-validator": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.1.0.tgz", - "integrity": "sha512-gHczt1xqQSwfNqBmOI3HjejtTljkiBEUneExMmTBLD0WwTC78lAqDvNMyydbySt3DhpH0F9oX7Vvuks6s5XPFw==", + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", - "ajv": "^8.11.0" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/ensure": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.1.0.tgz", - "integrity": "sha512-/S8Mo3Q1NtQUYDQjDmyQVPxfIwtnxq+guzMOkuGk8OSdwlzanm1WB9wDPIuuzlbMDDnBNbiAuBEUCcCNlfjrTQ==", + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", - "es-toolkit": "^1.46.0" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/execute-rule": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz", - "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==", + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/format": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.0.1.tgz", - "integrity": "sha512-ksmG2+cHGtuDPQQbhBbC4unwm444+6TiPw0d1bKf67hntgZqZ8E0g1MuYKUuyT5IH4IMmXZhKq22/Z3jBvtQIw==", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", - "picocolors": "^1.1.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/is-ignored": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.1.0.tgz", - "integrity": "sha512-RoRh1/YI+fYH+aid5lMQ2UD0vZ3p3Vf1KeUWT1ir3H/p/7T/6SFv1OiXLgLwUT8dP72EVWeEIyOfkiSWLZYVvw==", + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.1.0", - "semver": "^7.6.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/lint": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.1.0.tgz", - "integrity": "sha512-0DbfVVUjAWBfixW6v7CXXWVxMcj6Ukf/oB7O8NAbouP3jxmqUaC4eVQphxl3B3M0ii3cCQiR3sRAYxICwU2gAA==", + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.1.0", - "@commitlint/parse": "^21.1.0", - "@commitlint/rules": "^21.1.0", - "@commitlint/types": "^21.1.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/load": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.1.0.tgz", - "integrity": "sha512-juiClVEcoreNB0TNVkseO2EmNcpEs/Yhnmgbnm/hQAKBFRynKwIaoNIljXkx/3yvZcMO0EE8I2XOEI7d5KZG8Q==", + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.1.0", - "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.1.0", - "@commitlint/types": "^21.1.0", - "cosmiconfig": "^9.0.1", - "cosmiconfig-typescript-loader": "^6.1.0", - "es-toolkit": "^1.46.0", - "is-plain-obj": "^4.1.0", - "picocolors": "^1.1.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@commitlint/message": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.2.tgz", - "integrity": "sha512-5n4aqHGD/FNnom/D5L8i7cYtV+xjuXcBL832C3w9VglEsZzIsoHpJsvxzJ7cgiOsOdc/2jU4t5+7qMHh7GBX3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@commitlint/parse": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.1.0.tgz", - "integrity": "sha512-HdAqbbjQS8eEtbR74Ysg2VNmbvAfeWLVYMkip/lHibNrtjRsC/97XAYN3/H5P0pEJtDfyTb3iLs8x6y0eu4OYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^21.1.0", - "conventional-changelog-angular": "^8.2.0", - "conventional-commits-parser": "^6.3.0" + "node": ">=6.9.0" }, - "engines": { - "node": ">=22.12.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/read": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.1.0.tgz", - "integrity": "sha512-ID7m79aw8d0dMlxuXHD2QGxEX3Fhl/mUPA80WwEW5VgeOpUHNahhwWJefDdoBDVZcDfbHuf429NrcK0gxQsQjA==", + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^21.0.2", - "@commitlint/types": "^21.1.0", - "git-raw-commits": "^5.0.0", - "tinyexec": "^1.0.0" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" }, "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@commitlint/resolve-extends": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.1.0.tgz", - "integrity": "sha512-SANYkxJDfMl3TvnyALWHEaiF5nc6FFaOnh7VvfxjT4X2vD4i2gVHhmfMm1fsrBwDRX98/XyM1XDo5sAd/KXcyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^21.1.0", - "@commitlint/types": "^21.1.0", - "es-toolkit": "^1.46.0", - "global-directory": "^5.0.0", - "resolve-from": "^5.0.0" + "node": ">=6.9.0" }, - "engines": { - "node": ">=22.12.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/rules": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.1.0.tgz", - "integrity": "sha512-fOPEYSmKn1ZJptjLmCEjJfYqz0PUYr8ng6VY2ZW26sB7KtENR90CmAXHEmScBbOIZip+d/+OwqK12DFBuHTqsQ==", + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^21.1.0", - "@commitlint/message": "^21.0.2", - "@commitlint/to-lines": "^21.0.1", - "@commitlint/types": "^21.1.0" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@commitlint/to-lines": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz", - "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==", + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" } }, - "node_modules/@commitlint/top-level": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.2.tgz", - "integrity": "sha512-s9KKM+e+mXgFeIh4n7KmOGAVT3mkJ3Fp1bBYHIK5pjeUwlEMzp/tZfb5u0Poa680AsQTXMEMRxZi1vQ9m2X5ug==", + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "escalade": "^3.2.0" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" } }, - "node_modules/@commitlint/types": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.1.0.tgz", - "integrity": "sha512-YodnnnH1Cp+08nP8HGNJAIuB6L3/vdCTHVRTfF8Ik/wRCLOTsU9zwv3yO1cSPQRDa9CLYtE+UJ2K67r7CwMSFw==", + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "conventional-commits-parser": "^6.3.0", - "picocolors": "^1.1.1" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" } }, - "node_modules/@conventional-changelog/git-client": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", - "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/child-process-utils": "^1.0.0", - "@simple-libs/stream-utils": "^1.2.0", - "semver": "^7.5.2" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.4.0" - }, - "peerDependenciesMeta": { - "conventional-commits-filter": { - "optional": true - }, - "conventional-commits-parser": { - "optional": true - } + "node": ">=6.9.0" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@biomejs/biome": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.3.tgz", + "integrity": "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" }, "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@datastream/aws": { - "resolved": "packages/aws", - "link": true - }, - "node_modules/@datastream/base64": { - "resolved": "packages/base64", - "link": true - }, - "node_modules/@datastream/charset": { - "resolved": "packages/charset", - "link": true - }, - "node_modules/@datastream/compress": { - "resolved": "packages/compress", - "link": true - }, - "node_modules/@datastream/core": { - "resolved": "packages/core", - "link": true - }, - "node_modules/@datastream/csv": { - "resolved": "packages/csv", - "link": true - }, - "node_modules/@datastream/digest": { - "resolved": "packages/digest", - "link": true - }, - "node_modules/@datastream/encrypt": { - "resolved": "packages/encrypt", - "link": true - }, - "node_modules/@datastream/fetch": { - "resolved": "packages/fetch", - "link": true - }, - "node_modules/@datastream/file": { - "resolved": "packages/file", - "link": true - }, - "node_modules/@datastream/github-workflows": { - "resolved": ".github", - "link": true - }, - "node_modules/@datastream/indexeddb": { - "resolved": "packages/indexeddb", - "link": true - }, - "node_modules/@datastream/ipfs": { - "resolved": "packages/ipfs", - "link": true - }, - "node_modules/@datastream/json": { - "resolved": "packages/json", - "link": true - }, - "node_modules/@datastream/object": { - "resolved": "packages/object", - "link": true - }, - "node_modules/@datastream/string": { - "resolved": "packages/string", - "link": true - }, - "node_modules/@datastream/validate": { - "resolved": "packages/validate", - "link": true - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.3", + "@biomejs/cli-darwin-x64": "2.5.3", + "@biomejs/cli-linux-arm64": "2.5.3", + "@biomejs/cli-linux-arm64-musl": "2.5.3", + "@biomejs/cli-linux-x64": "2.5.3", + "@biomejs/cli-linux-x64-musl": "2.5.3", + "@biomejs/cli-win32-arm64": "2.5.3", + "@biomejs/cli-win32-x64": "2.5.3" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.3.tgz", + "integrity": "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.3.tgz", + "integrity": "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ==", "cpu": [ - "ppc64" + "x64" ], - "license": "MIT", + "dev": true, + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "aix" + "darwin" ], "engines": { - "node": ">=18" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.3.tgz", + "integrity": "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==", "cpu": [ - "arm" + "arm64" ], - "license": "MIT", + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=18" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.3.tgz", + "integrity": "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=18" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.3.tgz", + "integrity": "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==", "cpu": [ "x64" ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" + "dev": true, + "libc": [ + "glibc" ], - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=18" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.3.tgz", + "integrity": "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=18" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.3.tgz", + "integrity": "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { - "node": ">=18" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.3.tgz", + "integrity": "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { - "node": ">=18" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18" + "node": ">=22.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260708.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260708.1.tgz", + "integrity": "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==", "cpu": [ - "ia32" + "x64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260708.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260708.1.tgz", + "integrity": "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==", "cpu": [ - "loong64" + "arm64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260708.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260708.1.tgz", + "integrity": "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==", "cpu": [ - "mips64el" + "x64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260708.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260708.1.tgz", + "integrity": "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==", "cpu": [ - "ppc64" + "arm64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260708.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260708.1.tgz", + "integrity": "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==", "cpu": [ - "riscv64" + "x64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], + "node_modules/@cloudflare/workers-types": { + "version": "5.20260711.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260711.1.tgz", + "integrity": "sha512-yY6GXfyrHJa33EWaSzS5N56Lsll02kgHo4Qodz2l2DNl4L6L5yXBMZVMvcYArirdYA1xn+o8LKVRQGdRpmSMzA==", + "extraneous": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@commitlint/cli": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.1.tgz", + "integrity": "sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@commitlint/config-conventional": "^21.2.0", + "@commitlint/format": "^21.2.0", + "@commitlint/lint": "^21.2.0", + "@commitlint/load": "^21.2.0", + "@commitlint/read": "^21.2.1", + "@commitlint/types": "^21.2.0", + "tinyexec": "^1.0.0", + "yargs": "^18.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], + "node_modules/@commitlint/config-conventional": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz", + "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@commitlint/types": "^21.2.0", + "conventional-changelog-conventionalcommits": "^10.0.0" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], + "node_modules/@commitlint/config-validator": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz", + "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@commitlint/types": "^21.2.0", + "ajv": "^8.11.0" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], + "node_modules/@commitlint/ensure": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz", + "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@commitlint/types": "^21.2.0", + "es-toolkit": "^1.46.0" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], + "node_modules/@commitlint/execute-rule": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz", + "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], + "node_modules/@commitlint/format": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz", + "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@commitlint/types": "^21.2.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], + "node_modules/@commitlint/is-ignored": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz", + "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@commitlint/types": "^21.2.0", + "semver": "^7.6.0" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], + "node_modules/@commitlint/lint": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz", + "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@commitlint/is-ignored": "^21.2.0", + "@commitlint/parse": "^21.2.0", + "@commitlint/rules": "^21.2.0", + "@commitlint/types": "^21.2.0" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], + "node_modules/@commitlint/load": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz", + "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@commitlint/config-validator": "^21.2.0", + "@commitlint/execute-rule": "^21.0.1", + "@commitlint/resolve-extends": "^21.2.0", + "@commitlint/types": "^21.2.0", + "cosmiconfig": "^9.0.1", + "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", + "is-plain-obj": "^4.1.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], + "node_modules/@commitlint/message": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.2.0.tgz", + "integrity": "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], + "node_modules/@commitlint/parse": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz", + "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@commitlint/types": "^21.2.0", + "conventional-changelog-angular": "^9.0.0", + "conventional-commits-parser": "^7.0.0" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@fluent/syntax": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", - "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", - "license": "Apache-2.0", + "node_modules/@commitlint/read": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz", + "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^21.2.0", + "@commitlint/types": "^21.2.0", + "@conventional-changelog/git-client": "^3.0.0", + "tinyexec": "^1.0.0" + }, "engines": { - "node": ">=14.0.0", - "npm": ">=7.0.0" + "node": ">=22.12.0" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "node_modules/@commitlint/resolve-extends": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", + "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==", "dev": true, "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^21.2.0", + "@commitlint/types": "^21.2.0", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", + "resolve-from": "^5.0.0" + }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], + "node_modules/@commitlint/rules": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz", + "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^21.2.0", + "@commitlint/message": "^21.2.0", + "@commitlint/to-lines": "^21.0.1", + "@commitlint/types": "^21.2.0" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], + "node_modules/@commitlint/to-lines": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz", + "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "node": ">=22.12.0" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], + "node_modules/@commitlint/top-level": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.2.0.tgz", + "integrity": "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==", "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], + "node_modules/@commitlint/types": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz", + "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==", "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" + "license": "MIT", + "dependencies": { + "conventional-commits-parser": "^7.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], + "node_modules/@conventional-changelog/git-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.1.0.tgz", + "integrity": "sha512-Tqa/gHco2WJWa740NRjOrfKVvzIqxkZpecb8bemaQ8sKM5PXb1UK4uTyTb/1wIqNuOVaDOFxyBdhTIQZn6gdjQ==", "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^2.0.0", + "@simple-libs/stream-utils": "^2.0.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "conventional-commits-filter": "^6.0.1", + "conventional-commits-parser": "^7.0.1" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "node_modules/@conventional-changelog/template": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.1.tgz", + "integrity": "sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@datastream/arrow": { + "resolved": "packages/arrow", + "link": true + }, + "node_modules/@datastream/aws": { + "resolved": "packages/aws", + "link": true + }, + "node_modules/@datastream/base64": { + "resolved": "packages/base64", + "link": true + }, + "node_modules/@datastream/charset": { + "resolved": "packages/charset", + "link": true + }, + "node_modules/@datastream/compress": { + "resolved": "packages/compress", + "link": true + }, + "node_modules/@datastream/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@datastream/csv": { + "resolved": "packages/csv", + "link": true + }, + "node_modules/@datastream/digest": { + "resolved": "packages/digest", + "link": true + }, + "node_modules/@datastream/duckdb": { + "resolved": "packages/duckdb", + "link": true + }, + "node_modules/@datastream/encrypt": { + "resolved": "packages/encrypt", + "link": true + }, + "node_modules/@datastream/fetch": { + "resolved": "packages/fetch", + "link": true + }, + "node_modules/@datastream/file": { + "resolved": "packages/file", + "link": true + }, + "node_modules/@datastream/github-workflows": { + "resolved": ".github", + "link": true + }, + "node_modules/@datastream/indexeddb": { + "resolved": "packages/indexeddb", + "link": true + }, + "node_modules/@datastream/ipfs": { + "resolved": "packages/ipfs", + "link": true + }, + "node_modules/@datastream/json": { + "resolved": "packages/json", + "link": true + }, + "node_modules/@datastream/kafka": { + "resolved": "packages/kafka", + "link": true + }, + "node_modules/@datastream/object": { + "resolved": "packages/object", + "link": true + }, + "node_modules/@datastream/protobuf": { + "resolved": "packages/protobuf", + "link": true + }, + "node_modules/@datastream/schema-registry": { + "resolved": "packages/schema-registry", + "link": true + }, + "node_modules/@datastream/string": { + "resolved": "packages/string", + "link": true + }, + "node_modules/@datastream/validate": { + "resolved": "packages/validate", + "link": true + }, + "node_modules/@duckdb/duckdb-wasm": { + "version": "1.33.1-dev57.0", + "resolved": "https://registry.npmjs.org/@duckdb/duckdb-wasm/-/duckdb-wasm-1.33.1-dev57.0.tgz", + "integrity": "sha512-ndn5l2EHq1PEMvCg8G6lRV0vjhSUcbqdM/niNBTTqH4/PyyUUVKbge0uUyCnj27b9SF/CiqBwDsgKLTqBvaxdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "apache-arrow": "^17.0.0", + "qs": "^6.14.1" + } + }, + "node_modules/@duckdb/duckdb-wasm/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@duckdb/duckdb-wasm/node_modules/apache-arrow": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-17.0.0.tgz", + "integrity": "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^20.13.0", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^24.3.25", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.cjs" + } + }, + "node_modules/@duckdb/duckdb-wasm/node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@duckdb/duckdb-wasm/node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@duckdb/duckdb-wasm/node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@duckdb/duckdb-wasm/node_modules/flatbuffers": { + "version": "24.12.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz", + "integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@duckdb/duckdb-wasm/node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@duckdb/duckdb-wasm/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@duckdb/node-api": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-api/-/node-api-1.5.4-r.1.tgz", + "integrity": "sha512-3PYk/4//svuYmb9RYVM71cCoNa6ngoYWwrMhJaqbm010txzIMWbJCbxrFeqDl+krdIug+Hc/MNCeS0gHsTXSIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@duckdb/node-bindings": "1.5.4-r.1" + } + }, + "node_modules/@duckdb/node-bindings": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings/-/node-bindings-1.5.4-r.1.tgz", + "integrity": "sha512-v834OZZKQ59yAvh8GOEmM+R1foPNgdMGL0VTBgHywgblgQNyq11HvWgT4QGJIUFfo/cQ9WFyf1MFhK0+hV1aiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "optionalDependencies": { + "@duckdb/node-bindings-darwin-arm64": "1.5.4-r.1", + "@duckdb/node-bindings-darwin-x64": "1.5.4-r.1", + "@duckdb/node-bindings-linux-arm64": "1.5.4-r.1", + "@duckdb/node-bindings-linux-arm64-musl": "1.5.4-r.1", + "@duckdb/node-bindings-linux-x64": "1.5.4-r.1", + "@duckdb/node-bindings-linux-x64-musl": "1.5.4-r.1", + "@duckdb/node-bindings-win32-arm64": "1.5.4-r.1", + "@duckdb/node-bindings-win32-x64": "1.5.4-r.1" + } + }, + "node_modules/@duckdb/node-bindings-darwin-arm64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-arm64/-/node-bindings-darwin-arm64-1.5.4-r.1.tgz", + "integrity": "sha512-fl8EC5xdmI7VAI7bX4W6D7lOGNtxyLvSxGjOY8Ov7viVEKwIcBXXKlMPgh3sw+PiVlwZhKlknuCI8BL5xXpSDg==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-darwin-x64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-x64/-/node-bindings-darwin-x64-1.5.4-r.1.tgz", + "integrity": "sha512-E8bECZ6abJqunPqVR1NA0Zho7qxanfDWWFuJrKMsU1NCUqyGLyhXqZwsRHAx7J6jrM+lhQ7wOZj1x/N4OVml3A==", + "cpu": [ + "x64" ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "node_modules/@duckdb/node-bindings-linux-arm64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64/-/node-bindings-linux-arm64-1.5.4-r.1.tgz", + "integrity": "sha512-4EsB+cgRqOE++mdPQ2ZoGJCg4ylmuqdbLDtmo3L19B+C5OzGmrhxlKD4o75eGEtfO52M+w+TdL0qhy0H5XvaKQ==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "libc": [ "glibc" ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + ] }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "node_modules/@duckdb/node-bindings-linux-arm64-musl": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64-musl/-/node-bindings-linux-arm64-musl-1.5.4-r.1.tgz", + "integrity": "sha512-96Pyk5Syy18S5DSN0CKkOQ7/CsyVGRML8ewox1qpFDjYvPH1VsULlQoIRwbpw7mkFEy17wuWmbwzb7oKu/nGMw==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, "libc": [ - "glibc" + "musl" ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + ] }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "node_modules/@duckdb/node-bindings-linux-x64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64/-/node-bindings-linux-x64-1.5.4-r.1.tgz", + "integrity": "sha512-IldapRydno5kf4VkPCe8yK+j4pCg+j6Qs46UmLnKex/mtIdJa7ifhMYRkvdc5KIAFEC0eEL5c830QeuwcUROyg==", "cpu": [ - "s390x" + "x64" ], "dev": true, "libc": [ "glibc" ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + ] }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "node_modules/@duckdb/node-bindings-linux-x64-musl": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64-musl/-/node-bindings-linux-x64-musl-1.5.4-r.1.tgz", + "integrity": "sha512-tWgnttlKfWTktyv+m9YbxaedKBon5wmUoJ9DaIbE3D98KhhUaqmJhnjzso9O8hp6WUeRmwXR//hpT/X6Ft9nYA==", "cpu": [ "x64" ], "dev": true, "libc": [ - "glibc" + "musl" ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + ] }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "node_modules/@duckdb/node-bindings-win32-arm64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-arm64/-/node-bindings-win32-arm64-1.5.4-r.1.tgz", + "integrity": "sha512-77apmuNo51bPZzv8xjdeCp+hlU6JLwMPtS1CMViy83ONTsah+CGnpBx1lCnEqPL5Va0meufZyjSdZCVCijLdDA==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "win32" + ] }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "node_modules/@duckdb/node-bindings-win32-x64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-x64/-/node-bindings-win32-x64-1.5.4-r.1.tgz", + "integrity": "sha512-Wme7dScBGqjn2PUJ+RLFiFAblW1qQRBraX7SJc4uKtftZiUJJWZapEUh+doGfb68Qxvw8JhlMBSdaUsSUDJYsw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" + "win32" + ] + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "aix" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" + "arm" ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" + "arm64" ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ - "wasm32" + "arm64" ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ - "arm64" + "x64" ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ - "ia32" + "arm" ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ - "x64" + "arm64" ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@one-ini/wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", - "license": "MIT" - }, - "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@plausible-analytics/tracker": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz", - "integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==", - "license": "MIT" - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/dumper/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "netbsd" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ - "x64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "openbsd" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "openbsd" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ - "arm" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ - "ppc64" + "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ - "s390x" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", - "cpu": [ - "x64" - ], + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", + "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ - "linux" + "darwin" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "linux" + "darwin" ], - "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "openharmony" + "darwin" ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ - "wasm32" + "x64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, - "peer": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "win32" + "linux" ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "win32" + "linux" ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@silverbucket/ajv-formats-draft2019": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@silverbucket/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.5.tgz", - "integrity": "sha512-TUN/aSGMt3mZF45oy+kfCKtnKjBbSRVYFJxZKnGCPbKynPI2tsGyLgD9GEwFS0NLPbX0hUYHYeyFVoJ33HlMCw==", - "license": "MIT", - "dependencies": { - "@silverbucket/iana-schemes": "^1.4.4", - "smtp-address-parser": "^1.1.0", - "uri-js-replace": "^1.0.1" - }, - "peerDependencies": { - "ajv": "*" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@silverbucket/iana-schemes": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@silverbucket/iana-schemes/-/iana-schemes-1.4.4.tgz", - "integrity": "sha512-2iUk6DlqdpQQKs3qrCZDf4LQLH1GPtUXmYZFeq0NWw1WkGY2iJBP4aeYZ5v+3ITxtQA3M0t/Sua8AKu+o4O0KA==", - "license": "MIT" - }, - "node_modules/@simple-libs/child-process-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", - "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@simple-libs/stream-utils": "^1.2.0" - }, - "engines": { - "node": ">=18" - }, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://ko-fi.com/dangreen" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@simple-libs/stream-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", - "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://ko-fi.com/dangreen" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@simplewebauthn/browser": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", - "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", - "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", - "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "type-detect": "^4.1.0" + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@sinonjs/samsam/node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=4" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" } }, - "node_modules/@smithy/core": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", - "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.15.0", - "tslib": "^2.6.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz", - "integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.26.0", - "@smithy/types": "^4.15.0", - "tslib": "^2.6.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz", - "integrity": "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.6.tgz", - "integrity": "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", - "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" } }, - "node_modules/@smithy/types": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", - "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, - "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "CC0-1.0" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], "dev": true, - "license": "MIT" + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", - "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@sveltejs/adapter-cloudflare": { - "version": "7.2.8", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-cloudflare/-/adapter-cloudflare-7.2.8.tgz", - "integrity": "sha512-bIdhY/Fi4AQmqiBdQVKnafH1h9Gw+xbCvHyUu4EouC8rJOU02zwhi14k/FDhQ0mJF1iblIu3m8UNQ8GpGIvIOQ==", + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", "dev": true, "license": "MIT", "dependencies": { - "@cloudflare/workers-types": "^4.20250507.0", - "worktop": "0.8.0-next.18" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { - "@sveltejs/kit": "^2.0.0", - "wrangler": "^4.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@sveltejs/kit": { - "version": "2.64.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.64.0.tgz", - "integrity": "sha512-24MXA/xyugUqJd09DD4v4bLd4k7FaYhdBck/pTXiu3XFtDBcwXYpJc1SO1+lqdjkNBUo1r4eek5FS7cfzBQUmA==", + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.9", - "@types/cookie": "^0.6.0", - "acorn": "^8.16.0", - "cookie": "^0.6.0", - "devalue": "^5.8.1", - "esm-env": "^1.2.2", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "set-cookie-parser": "^3.0.0", - "sirv": "^3.0.0" - }, - "bin": { - "svelte-kit": "svelte-kit.js" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18.13" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3 || ^6.0.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + "@types/node": ">=18" }, "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "typescript": { + "@types/node": { "optional": true } } }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", - "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "dev": true, "license": "MIT", "dependencies": { - "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", - "obug": "^2.1.0", - "vitefu": "^1.1.2" + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": "^20.19 || ^22.12 || >=24" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { - "svelte": "^5.46.4", - "vite": "^8.0.0-beta.7 || ^8.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", "dev": true, "license": "MIT", "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/unist": "*" + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "*" + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@types/minimatch": { + "node_modules/@inquirer/input": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", - "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/sinon": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", - "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", "dev": true, "license": "MIT", "dependencies": { - "@types/sinonjs__fake-timers": "*" - } - }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", - "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@ungap/custom-elements": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/custom-elements/-/custom-elements-1.3.0.tgz", - "integrity": "sha512-f4q/s76+8nOy+fhrNHyetuoPDR01lmlZB5czfCG+OOnBw/Wf+x48DcCDPmMQY7oL8xYFL8qfenMoiS8DUkKBUw==", - "license": "ISC" - }, - "node_modules/@willfarrell-ds/cli": { - "version": "0.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/@willfarrell-ds/cli/-/cli-0.0.0-alpha.6.tgz", - "integrity": "sha512-CIEnRyopUAil0yPrAYqg8jEnSwc1dzIe5LQbi0/ghuWukSHrwcrWGoqBwTfuBOddg3087kIHj3YXHyJncOG1qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "3.1.3", - "commander": "14.0.3", - "mathjs": "15.1.1" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, - "bin": { - "ds": "index.js" - } - }, - "node_modules/@willfarrell-ds/svelte": { - "version": "0.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/@willfarrell-ds/svelte/-/svelte-0.0.0-alpha.6.tgz", - "integrity": "sha512-6xnvXlOWj6h/kWIx6RG0J28XmmwMTIVp6zxqeZlmifymS8l28G8qsg2tCHyaN+09gw7hTvLoQ2P4wCF2FMr9HA==", - "license": "MIT", - "dependencies": { - "@willfarrell-ds/vanilla": "0.0.0-alpha.6", - "pretty": "2.0.0", - "prismjs": "1.30.0", - "tinykeys": "3.0.0" + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { - "svelte": "^5.0.0" - } - }, - "node_modules/@willfarrell-ds/vanilla": { - "version": "0.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/@willfarrell-ds/vanilla/-/vanilla-0.0.0-alpha.6.tgz", - "integrity": "sha512-a7fWbPJZkDcMQ/OK1pLMSAk6DIBBVN52VaJrXu/lUur4VUrX3Sa0W3UVPAQgWaJ5gkFYfxBjTw3rVkm2PwWLGQ==", - "license": "MIT", - "dependencies": { - "@simplewebauthn/browser": "13.3.0", - "@ungap/custom-elements": "1.3.0", - "accessible-autocomplete": "3.0.1" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@yarnpkg/parsers": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.3.tgz", - "integrity": "sha512-mQZgUSgFurUtA07ceMjxrWkYz8QtDuYkvPlu0ZqncgjopQ0t6CNEo/OSealkmnagSUx8ZD5ewvezUwUuMqutQg==", + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "js-yaml": "^3.10.0", - "tslib": "^2.4.0" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=18.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@yarnpkg/parsers/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", "dev": true, "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/accessible-autocomplete": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/accessible-autocomplete/-/accessible-autocomplete-3.0.1.tgz", - "integrity": "sha512-xMshgc2LT5addvvfCTGzIkRrvhbOFeylFSnSMfS/PdjvvvElZkakCwxO3/yJYBWyi1hi3tZloqOJQ5kqqJtH4g==", - "license": "MIT", + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, "peerDependencies": { - "preact": "^8.0.0" + "@types/node": ">=18" }, "peerDependenciesMeta": { - "preact": { + "@types/node": { "optional": true } } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/ajv-cmd": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/ajv-cmd/-/ajv-cmd-0.13.3.tgz", - "integrity": "sha512-H1LArE4kclZ5JdWLKUZd1ji3UjO6FSpCW1MZ6J0X89BqwT+5W70KD4TpfvhcM7isSoQvo6sslA/rZiCH1HzM6g==", + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "dev": true, "license": "MIT", - "workspaces": [ - ".github" - ], "dependencies": { - "@apidevtools/json-schema-ref-parser": "15.3.5", - "@silverbucket/ajv-formats-draft2019": "1.6.5", - "ajv": "8.20.0", - "ajv-errors": "3.0.0", - "ajv-formats": "3.0.1", - "ajv-ftl-i18n": "0.2.1", - "ajv-i18n": "4.2.0", - "ajv-keywords": "5.1.0", - "commander": "14.0.3", - "esbuild": "^0.28.0", - "sast-json-schema": "^0.4.0" - }, - "bin": { - "ajv": "cli.js" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=24" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/willfarrell" - } - }, - "node_modules/ajv-errors": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", - "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", - "license": "MIT", "peerDependencies": { - "ajv": "^8.0.1" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { - "ajv": "^8.0.0" + "@types/node": ">=18" }, "peerDependenciesMeta": { - "ajv": { + "@types/node": { "optional": true } } }, - "node_modules/ajv-ftl-i18n": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/ajv-ftl-i18n/-/ajv-ftl-i18n-0.2.1.tgz", - "integrity": "sha512-ThWHxPmLaJYKrl1mMWAYizGqZG1MrHcMgQ4MId2YFAjRn73GrZqSMX+sifQAHXAva0RZVeGHxVqkrWGizDFo6Q==", - "license": "MIT", - "workspaces": [ - ".github" - ], + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", "dependencies": { - "commander": "14.0.3", - "fluent-transpiler": "0.4.1" - }, - "bin": { - "ajv-ftl": "cli.js" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": ">=24" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/willfarrell" + "node": ">=12" } }, - "node_modules/ajv-i18n": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", - "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==", - "license": "MIT", - "peerDependencies": { - "ajv": "^8.0.0-beta.0" - } + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "license": "Apache-2.0", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=6.0.0" } }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/aws-sdk-client-mock": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-4.1.0.tgz", - "integrity": "sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/sinon": "^17.0.3", - "sinon": "^18.0.1", - "tslib": "^2.1.0" + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "license": "Apache-2.0", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, "engines": { - "node": ">= 0.4" + "node": ">= 8" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">= 8" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", "license": "MIT" }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, - "license": "ISC" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz", + "integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==", + "license": "MIT" + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "kleur": "^4.1.5" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" } }, - "node_modules/brotli-wasm": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/brotli-wasm/-/brotli-wasm-3.0.1.tgz", - "integrity": "sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==", + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=v18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "dev": true, - "license": "MIT", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } + "license": "MIT" }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/change-case": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", - "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "license": "MIT" - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, - "node_modules/cheerio": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", - "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "encoding-sniffer": "^0.2.1", - "htmlparser2": "^10.1.0", - "parse5": "^7.3.0", - "parse5-htmlparser2-tree-adapter": "^7.1.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^7.19.0", - "whatwg-mimetype": "^4.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=20.18.1" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/cheerio/node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.18.1" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.6" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/complex.js": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz", - "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/condense-newlines": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz", - "integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "extend-shallow": "^2.0.1", - "is-whitespace": "^0.3.0", - "kind-of": "^3.0.2" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { - "node": ">=0.10.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/config-chain/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/conventional-changelog-angular": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", - "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", - "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@silverbucket/ajv-formats-draft2019": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@silverbucket/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.5.tgz", + "integrity": "sha512-TUN/aSGMt3mZF45oy+kfCKtnKjBbSRVYFJxZKnGCPbKynPI2tsGyLgD9GEwFS0NLPbX0hUYHYeyFVoJ33HlMCw==", + "license": "MIT", "dependencies": { - "compare-func": "^2.0.0" + "@silverbucket/iana-schemes": "^1.4.4", + "smtp-address-parser": "^1.1.0", + "uri-js-replace": "^1.0.1" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "ajv": "*" } }, - "node_modules/conventional-commits-parser": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", - "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "node_modules/@silverbucket/iana-schemes": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@silverbucket/iana-schemes/-/iana-schemes-1.4.4.tgz", + "integrity": "sha512-2iUk6DlqdpQQKs3qrCZDf4LQLH1GPtUXmYZFeq0NWw1WkGY2iJBP4aeYZ5v+3ITxtQA3M0t/Sua8AKu+o4O0KA==", + "license": "MIT" + }, + "node_modules/@simple-libs/child-process-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", + "integrity": "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" + "@simple-libs/stream-utils": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/@simple-libs/stream-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", + "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "dev": true, "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/cosmiconfig-typescript-loader": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", - "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, "license": "MIT", - "dependencies": { - "jiti": "2.6.1" - }, "engines": { - "node": ">=v18" + "node": ">=18" }, - "peerDependencies": { - "@types/node": "*", - "cosmiconfig": ">=9", - "typescript": ">=5" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" + "type-detect": "4.0.8" } }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", "dev": true, - "license": "BSD-2-Clause", + "license": "BSD-3-Clause", "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "@sinonjs/commons": "^3.0.0" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" } }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "node": ">=4" } }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "node_modules/@smithy/core": { + "version": "3.29.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.3.tgz", + "integrity": "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "css-tree": "~2.2.0" + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">=18.0.0" } }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.8.tgz", + "integrity": "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" + "@smithy/core": "^3.29.3", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">=18.0.0" } }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.5.tgz", + "integrity": "sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g==", "dev": true, - "license": "CC0-1.0" - }, - "node_modules/datastream.js.org": { - "resolved": "websites/datastream.js.org", - "link": true + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.3", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ms": "^2.1.3" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=14.0.0" } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "node_modules/@smithy/node-http-handler": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.5.tgz", + "integrity": "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.3", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">=18.0.0" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/@smithy/signature-v4": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.4.tgz", + "integrity": "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.3", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "license": "MIT" - }, - "node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "dev": true, - "license": "BSD-3-Clause", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=0.3.1" + "node": ">=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "path-type": "^4.0.0" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", - "license": "MIT" - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "node_modules/@smithy/util-hex-encoding": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.2.0.tgz", + "integrity": "sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "node_modules/@smithy/util-middleware": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.2.0.tgz", + "integrity": "sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==", "dev": true, - "license": "BSD-2-Clause", + "license": "Apache-2.0", "dependencies": { - "domelementtype": "^2.3.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "node": ">=14.0.0" } }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "node_modules/@smithy/util-middleware/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", "dev": true, - "license": "BSD-2-Clause", + "license": "Apache-2.0", "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "node_modules/@smithy/util-uri-escape": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.2.0.tgz", + "integrity": "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "is-obj": "^2.0.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/editorconfig": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", - "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", - "license": "MIT", + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@one-ini/wasm": "0.1.1", - "commander": "^10.0.0", - "minimatch": "^9.0.1", - "semver": "^7.5.3" - }, - "bin": { - "editorconfig": "bin/editorconfig" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" } }, - "node_modules/editorconfig/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, - "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "license": "MIT", + "node_modules/@stryker-mutator/api": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-9.6.1.tgz", + "integrity": "sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/editorconfig/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "license": "MIT", + "mutation-testing-metrics": "3.7.3", + "mutation-testing-report-schema": "3.7.3", + "tslib": "~2.8.0", + "typed-inject": "~5.0.0" + }, "engines": { - "node": ">=14" + "node": ">=20.0.0" } }, - "node_modules/editorconfig/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "node_modules/@stryker-mutator/core": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-9.6.1.tgz", + "integrity": "sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^2.0.2" + "@inquirer/prompts": "^8.0.0", + "@stryker-mutator/api": "9.6.1", + "@stryker-mutator/instrumenter": "9.6.1", + "@stryker-mutator/util": "9.6.1", + "ajv": "~8.18.0", + "chalk": "~5.6.0", + "commander": "~14.0.0", + "diff-match-patch": "1.0.5", + "emoji-regex": "~10.6.0", + "execa": "~9.6.0", + "json-rpc-2.0": "^1.7.0", + "lodash.groupby": "~4.6.0", + "minimatch": "~10.2.4", + "mutation-server-protocol": "~0.4.0", + "mutation-testing-elements": "3.7.3", + "mutation-testing-metrics": "3.7.3", + "mutation-testing-report-schema": "3.7.3", + "npm-run-path": "~6.0.0", + "progress": "~2.0.3", + "rxjs": "~7.8.1", + "semver": "^7.6.3", + "source-map": "~0.7.4", + "tree-kill": "~1.2.2", + "tslib": "2.8.1", + "typed-inject": "~5.0.0", + "typed-rest-client": "~2.3.0" }, - "engines": { - "node": ">=16 || 14 >=14.17" + "bin": { + "stryker": "bin/stryker.js" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encoding-sniffer": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "node_modules/@stryker-mutator/core/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "iconv-lite": "^0.6.3", - "whatwg-encoding": "^3.1.1" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/encoding-sniffer/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/@stryker-mutator/instrumenter": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-9.6.1.tgz", + "integrity": "sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "@babel/core": "~7.29.0", + "@babel/generator": "~7.29.0", + "@babel/parser": "~7.29.0", + "@babel/plugin-proposal-decorators": "~7.29.0", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/preset-typescript": "~7.28.0", + "@stryker-mutator/api": "9.6.1", + "@stryker-mutator/util": "9.6.1", + "angular-html-parser": "~10.4.0", + "semver": "~7.7.0", + "tslib": "2.8.1", + "weapon-regex": "~1.3.2" }, "engines": { - "node": ">=0.10.0" + "node": ">=20.0.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/@stryker-mutator/instrumenter/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "engines": { + "node": ">=10" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "node_modules/@stryker-mutator/util": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-9.6.1.tgz", + "integrity": "sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==", "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", "license": "MIT", - "engines": { - "node": ">=6" + "peerDependencies": { + "acorn": "^8.9.0" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", "dev": true, "license": "MIT", "dependencies": { - "is-arrayish": "^0.2.1" + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" } }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" } }, - "node_modules/es-toolkit": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", - "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/@types/buffers": { + "version": "0.1.31", + "resolved": "https://registry.npmjs.org/@types/buffers/-/buffers-0.1.31.tgz", + "integrity": "sha512-wEZBb3o0Kh5RAj3V172vJCcxaCV8C2HJ7YLBBlG5Mwue0g4uRg5LWv8C6ap8MyFbXE6UbYEuvtHY7oTWAPeXEw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@types/node": "*" } }, - "node_modules/escape-latex": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", - "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", - "dev": true, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", "license": "MIT" }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", "license": "MIT" }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } + "license": "MIT" }, - "node_modules/esrap": { - "version": "2.2.11", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", - "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" + "@types/minimatch": "*", + "@types/node": "*" } }, - "node_modules/fast-check": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", - "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" + "@types/unist": "*" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" + "@types/unist": "*" } }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } + "license": "MIT" }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "node_modules/@types/sinon": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" + "@types/sinonjs__fake-timers": "*" } }, - "node_modules/fluent-transpiler": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/fluent-transpiler/-/fluent-transpiler-0.4.1.tgz", - "integrity": "sha512-9CNRxPbnMDTt1hdtD2YPdbyH/p5o3xRgkcsZm0dgER+Kg2k3mdvDb4BJ6lVeFx+cGKp6GWPxcZ2n8R4b45JqJw==", - "license": "MIT", - "workspaces": [ - ".github" - ], - "dependencies": { - "@fluent/syntax": "0.19.0", - "change-case": "5.4.4", - "commander": "14.0.3" - }, - "bin": { - "ftl": "cli.js" - }, - "engines": { - "node": ">=24" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/willfarrell" - } + "node_modules/@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", + "dev": true, + "license": "MIT" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "peer": true, "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" + "node": ">=16.20.0" } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">=6 <7 || >=8" + "node": ">=16.20.0" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], "dev": true, - "hasInstallScript": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=16.20.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=16.20.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=16.20.0" } }, - "node_modules/git-raw-commits": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", - "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@conventional-changelog/git-client": "^2.6.0", - "meow": "^13.0.0" - }, - "bin": { - "git-raw-commits": "src/cli.js" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=18" + "node": ">=16.20.0" } }, - "node_modules/gitignore-to-glob": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/gitignore-to-glob/-/gitignore-to-glob-0.3.0.tgz", - "integrity": "sha512-mk74BdnK7lIwDHnotHddx1wsjMOFIThpLY3cPNniJ/2fA/tlLzHnFxIdR+4sLOu5KGgQJdij4kjJ2RoUNnCNMA==", + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=4.4 <5 || >=6.9" + "node": ">=16.20.0" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=16.20.0" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">= 6" + "node": ">=16.20.0" } }, - "node_modules/global-directory": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", - "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "ini": "6.0.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=16.20.0" } }, - "node_modules/globby": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", - "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=8" + "node": ">=16.20.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "ISC" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=8" + "node": ">=16.20.0" } }, - "node_modules/hash-wasm": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", - "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", - "license": "MIT" - }, - "node_modules/hast-util-to-string": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", - "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" } }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" + "peer": true, + "engines": { + "node": ">=16.20.0" } }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=16.20.0" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "node": ">=16.20.0" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=16.20.0" } }, - "node_modules/idb": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", - "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", - "license": "ISC" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" ], - "license": "BSD-3-Clause" + "peer": true, + "engines": { + "node": ">=16.20.0" + } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": ">= 4" + "node": ">=16.20.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/@ungap/custom-elements": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/custom-elements/-/custom-elements-1.3.0.tgz", + "integrity": "sha512-f4q/s76+8nOy+fhrNHyetuoPDR01lmlZB5czfCG+OOnBw/Wf+x48DcCDPmMQY7oL8xYFL8qfenMoiS8DUkKBUw==", + "license": "ISC" + }, + "node_modules/@willfarrell-ds/cli": { + "version": "0.0.0-alpha.8", + "resolved": "https://registry.npmjs.org/@willfarrell-ds/cli/-/cli-0.0.0-alpha.8.tgz", + "integrity": "sha512-UgxKWi7Pj3f3G/tr/mZV+dSFZn9htjp5iDzzWWfwmC/8JBxzapSSUaG46VY33a/y3zTro6ruuY2oGX7Hi5uBmg==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" + "color-convert": "3.1.3", + "commander": "15.0.0", + "mathjs": "15.2.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "ds": "index.js" } }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/@willfarrell-ds/cli/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=22.12.0" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", + "node_modules/@willfarrell-ds/svelte": { + "version": "0.0.0-alpha.8", + "resolved": "https://registry.npmjs.org/@willfarrell-ds/svelte/-/svelte-0.0.0-alpha.8.tgz", + "integrity": "sha512-S5a/qPwPa5n2POUs8XyfmzXFbXouJ+zQuSjnJIuA7R7iF2CLRHUfc2a/QeV8bD1E/L5aCGTlqtBLpTw7Z3XS0g==", + "license": "MIT", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "@willfarrell-ds/vanilla": "0.0.0-alpha.8", + "pretty": "2.0.0", + "prismjs": "1.30.0" + }, + "peerDependencies": { + "svelte": "^5.55.7" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" + "node_modules/@willfarrell-ds/vanilla": { + "version": "0.0.0-alpha.8", + "resolved": "https://registry.npmjs.org/@willfarrell-ds/vanilla/-/vanilla-0.0.0-alpha.8.tgz", + "integrity": "sha512-XpTDThlFuX338TGXtwQWL1uryddY85rsAeFg7IyMFBrS/8vJuZr/5wmW82LX5bB+h6DR45IiUtvB262VXMI+jg==", + "license": "MIT", + "dependencies": { + "@simplewebauthn/browser": "13.3.0", + "@ungap/custom-elements": "1.3.0", + "accessible-autocomplete": "3.0.1", + "tinykeys": "4.0.0" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "node_modules/@yarnpkg/parsers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.3.tgz", + "integrity": "sha512-mQZgUSgFurUtA07ceMjxrWkYz8QtDuYkvPlu0ZqncgjopQ0t6CNEo/OSealkmnagSUx8ZD5ewvezUwUuMqutQg==", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "dependencies": { + "js-yaml": "^3.10.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=18.12.0" + } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT" + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "node_modules/accessible-autocomplete": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/accessible-autocomplete/-/accessible-autocomplete-3.0.1.tgz", + "integrity": "sha512-xMshgc2LT5addvvfCTGzIkRrvhbOFeylFSnSMfS/PdjvvvElZkakCwxO3/yJYBWyi1hi3tZloqOJQ5kqqJtH4g==", "license": "MIT", - "engines": { - "node": ">=0.10.0" + "peerDependencies": { + "preact": "^8.0.0" + }, + "peerDependenciesMeta": { + "preact": { + "optional": true + } } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, "engines": { - "node": ">=0.10.0" + "node": ">=0.4.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "node_modules/ajv-cmd": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/ajv-cmd/-/ajv-cmd-0.13.3.tgz", + "integrity": "sha512-H1LArE4kclZ5JdWLKUZd1ji3UjO6FSpCW1MZ6J0X89BqwT+5W70KD4TpfvhcM7isSoQvo6sslA/rZiCH1HzM6g==", "license": "MIT", + "workspaces": [ + ".github" + ], "dependencies": { - "is-extglob": "^2.1.1" + "@apidevtools/json-schema-ref-parser": "15.3.5", + "@silverbucket/ajv-formats-draft2019": "1.6.5", + "ajv": "8.20.0", + "ajv-errors": "3.0.0", + "ajv-formats": "3.0.1", + "ajv-ftl-i18n": "0.2.1", + "ajv-i18n": "4.2.0", + "ajv-keywords": "5.1.0", + "commander": "14.0.3", + "esbuild": "^0.28.0", + "sast-json-schema": "^0.4.0" + }, + "bin": { + "ajv": "cli.js" }, "engines": { - "node": ">=0.10.0" + "node": ">=24" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/willfarrell" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", "license": "MIT", - "engines": { - "node": ">=0.12.0" + "peerDependencies": { + "ajv": "^8.0.1" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, + "node_modules/ajv-ftl-i18n": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ajv-ftl-i18n/-/ajv-ftl-i18n-0.2.1.tgz", + "integrity": "sha512-ThWHxPmLaJYKrl1mMWAYizGqZG1MrHcMgQ4MId2YFAjRn73GrZqSMX+sifQAHXAva0RZVeGHxVqkrWGizDFo6Q==", "license": "MIT", + "workspaces": [ + ".github" + ], + "dependencies": { + "commander": "14.0.3", + "fluent-transpiler": "0.4.1" + }, + "bin": { + "ajv-ftl": "cli.js" + }, "engines": { - "node": ">=12" + "node": ">=24" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/willfarrell" } }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "node_modules/ajv-i18n": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", + "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.0-beta.0" + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", "dependencies": { - "@types/estree": "^1.0.6" + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/is-whitespace": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", - "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==", + "node_modules/angular-html-parser": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.4.0.tgz", + "integrity": "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 14" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/javascript-natural-sort": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", - "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", - "license": "MIT", + "node_modules/apache-arrow": { + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", + "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", + "license": "Apache-2.0", "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.4.2", - "js-cookie": "^3.0.5", - "nopt": "^7.2.1" + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^24.0.3", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^25.1.24", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" }, "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" - }, - "engines": { - "node": ">=14" + "arrow2csv": "bin/arrow2csv.js" } }, - "node_modules/js-beautify/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/js-beautify/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "node_modules/apache-arrow/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "undici-types": "~7.18.0" } }, - "node_modules/js-beautify/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "node_modules/apache-arrow/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/argue-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.1.0.tgz", + "integrity": "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/js-beautify/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 0.4" } }, - "node_modules/js-cookie": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", - "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", + "node_modules/array-back": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", + "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=12.17" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-msk-iam-sasl-signer-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/aws-msk-iam-sasl-signer-js/-/aws-msk-iam-sasl-signer-js-1.0.3.tgz", + "integrity": "sha512-bFsI5p7HAUtVxXLNWLgbcLGFKiUuoTphCNq/UoSW5669LXomh2qI7gAvY0JP8l0UaLXjNKr+D+MeQY0Gqm8RKg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "argparse": "^2.0.1" + "@aws-crypto/sha256-js": "^4.0.0", + "@aws-sdk/client-sts": "^3.993.0", + "@aws-sdk/credential-providers": "^3.993.0", + "@aws-sdk/util-format-url": "^3.347.0", + "@smithy/signature-v4": "^2.0.1", + "@types/buffers": "0.1.31" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=14.x" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "node_modules/aws-msk-iam-sasl-signer-js/node_modules/@smithy/signature-v4": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.3.0.tgz", + "integrity": "sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/util-hex-encoding": "^2.2.0", + "@smithy/util-middleware": "^2.2.0", + "@smithy/util-uri-escape": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=14.0.0" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "node_modules/aws-msk-iam-sasl-signer-js/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "node_modules/aws-sdk-client-mock": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-4.1.0.tgz", + "integrity": "sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinon": "^17.0.3", + "sinon": "^18.0.1", + "tslib": "^2.1.0" + } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/just-extend": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", - "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, - "node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, "engines": { - "node": ">=0.10.0" + "node": ">=6.0.0" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "MIT" }, - "node_modules/libsodium": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz", - "integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==", + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true, "license": "ISC" }, - "node_modules/libsodium-wrappers": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz", - "integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==", + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "dev": true, - "license": "ISC", - "dependencies": { - "libsodium": "^0.8.0" - } + "license": "MIT" }, - "node_modules/license-check-and-add": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/license-check-and-add/-/license-check-and-add-4.0.5.tgz", - "integrity": "sha512-FySnMi3Kf/vO5jka8tcbVF1FhDFb8PWsQ8pg5Y7U/zkQgta+fIrJGcGHO58WFjfKlgvhneG1uQ00Fpxzhau3QA==", + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "fs-extra": "^8.1.0", - "gitignore-to-glob": "^0.3.0", - "globby": "^10.0.1", - "ignore": "^5.1.2", - "yargs": "^13.3.0" + "balanced-match": "^4.0.2" }, - "bin": { - "license-check-and-add": "dist/src/cli.js" - } - }, - "node_modules/license-check-and-add/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=6" + "node": "18 || 20 || >=22" } }, - "node_modules/license-check-and-add/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/license-check-and-add/node_modules/cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "node_modules/brotli-wasm": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/brotli-wasm/-/brotli-wasm-3.0.1.tgz", + "integrity": "sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==", "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" + "license": "Apache-2.0", + "engines": { + "node": ">=v18.0.0" } }, - "node_modules/license-check-and-add/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/license-check-and-add/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/license-check-and-add/node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true, - "license": "MIT" - }, - "node_modules/license-check-and-add/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" } }, - "node_modules/license-check-and-add/node_modules/string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/license-check-and-add/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^4.1.0" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/license-check-and-add/node_modules/wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" - }, "engines": { "node": ">=6" } }, - "node_modules/license-check-and-add/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/license-check-and-add/node_modules/yargs": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", - "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, "license": "MIT", - "dependencies": { - "cliui": "^5.0.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^13.1.2" + "engines": { + "node": ">=6" } }, - "node_modules/license-check-and-add/node_modules/yargs-parser": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", - "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 12.0.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=8" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/chalk-template/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=7.0.0" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], + "node_modules/chalk-template/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "license": "MIT" + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "license": "MIT" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.18.1" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], + "node_modules/cheerio/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=20.18.1" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">= 12" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=20" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=6" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" + "color-name": "^2.0.0" }, "engines": { - "node": ">=6" + "node": ">=14.6" } }, - "node_modules/lockfile-lint": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/lockfile-lint/-/lockfile-lint-5.0.0.tgz", - "integrity": "sha512-QcVIVITLZAhWYHU2wbNSOMgwc6EN4Y2sy6mjgS5aikYyRzgDIfotXUsCrm38En+3fZpc58Yu7DF9dNeT/goi1A==", + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "cosmiconfig": "^9.0.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "lockfile-lint-api": "^5.9.2", - "yargs": "^17.7.2" - }, - "bin": { - "lockfile-lint": "bin/lockfile-lint.js" - }, + "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": ">=12.20" } }, - "node_modules/lockfile-lint-api": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/lockfile-lint-api/-/lockfile-lint-api-5.9.2.tgz", - "integrity": "sha512-3QhxWxl3jT9GcMxuCnTsU8Tz5U6U1lKBlKBu2zOYOz/x3ONUoojEtky3uzoaaDgExcLqIX0Aqv2I7TZXE383CQ==", - "dev": true, - "license": "Apache-2.0", + "node_modules/command-line-args": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.2.tgz", + "integrity": "sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==", + "license": "MIT", "dependencies": { - "@yarnpkg/parsers": "^3.0.0-rc.48.1", - "debug": "^4.3.4", - "object-hash": "^3.0.0" + "array-back": "^6.2.3", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.3.0" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/lockfile-lint/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/command-line-usage": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", + "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.1", + "typical": "^7.3.0" + }, "engines": { - "node": ">=8" + "node": ">=12.20.0" } }, - "node_modules/lockfile-lint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/complex.js": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz", + "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==", "dev": true, "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/condense-newlines": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz", + "integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==", + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "extend-shallow": "^2.0.1", + "is-whitespace": "^0.3.0", + "kind-of": "^3.0.2" }, "engines": { - "node": ">=8" + "node": ">=0.10.0" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/conventional-changelog-angular": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz", + "integrity": "sha512-oWSL6ZhnXbYraOFTK3PgRAQJ8fADDAEv5K6AdeyQPLvjFmhG8+ejL0jZZp/R7vTmGJaBvZEE+sE7dB4bCv7sAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@conventional-changelog/template": "^1.2.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">=22" } }, - "node_modules/lockfile-lint/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/conventional-changelog-conventionalcommits": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.1.tgz", + "integrity": "sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "@conventional-changelog/template": "^1.2.1" }, "engines": { - "node": ">=12" + "node": ">=22" } }, - "node_modules/lockfile-lint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/conventional-commits-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.0.tgz", + "integrity": "sha512-DPp6hkUjvwIivxbkrTiLXeRswNv1A/4GFA2X6scXma0AMa9632V3TwxmrlkUIEtUktiM3Ln+RrSH2xlP3/jUTw==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@simple-libs/stream-utils": "^2.0.0", + "argue-cli": "^3.1.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" }, "engines": { - "node": ">=7.0.0" + "node": ">=22" } }, - "node_modules/lockfile-lint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, - "node_modules/lockfile-lint/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, - "node_modules/lockfile-lint/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" }, "engines": { - "node": ">=8" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/lockfile-lint/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "jiti": "2.6.1" }, "engines": { - "node": ">=8" + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" } }, - "node_modules/lockfile-lint/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/lockfile-lint/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=12" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/lockfile-lint/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause", "engines": { - "node": ">=12" + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", "dev": true, - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" } }, - "node_modules/mathjs": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", - "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/datastream.js.org": { + "resolved": "websites/datastream.js.org", + "link": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.26.10", - "complex.js": "^2.2.5", - "decimal.js": "^10.4.3", - "escape-latex": "^1.2.0", - "fraction.js": "^5.2.1", - "javascript-natural-sort": "^0.7.1", - "seedrandom": "^3.0.5", - "tiny-emitter": "^2.1.0", - "typed-function": "^4.2.1" + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" }, "bin": { - "mathjs": "bin/cli.js" + "editorconfig": "bin/editorconfig" }, "engines": { - "node": ">= 18" + "node": ">=14" } }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0" + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-replace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/fluent-transpiler": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fluent-transpiler/-/fluent-transpiler-0.4.1.tgz", + "integrity": "sha512-9CNRxPbnMDTt1hdtD2YPdbyH/p5o3xRgkcsZm0dgER+Kg2k3mdvDb4BJ6lVeFx+cGKp6GWPxcZ2n8R4b45JqJw==", + "license": "MIT", + "workspaces": [ + ".github" + ], + "dependencies": { + "@fluent/syntax": "0.19.0", + "change-case": "5.4.4", + "commander": "14.0.3" + }, + "bin": { + "ftl": "cli.js" + }, + "engines": { + "node": ">=24" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/willfarrell" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gitignore-to-glob": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/gitignore-to-glob/-/gitignore-to-glob-0.3.0.tgz", + "integrity": "sha512-mk74BdnK7lIwDHnotHddx1wsjMOFIThpLY3cPNniJ/2fA/tlLzHnFxIdR+4sLOu5KGgQJdij4kjJ2RoUNnCNMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.4 <5 || >=6.9" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/global-directory": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "6.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", + "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-whitespace": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", + "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-rpc-2.0": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.7.1.tgz", + "integrity": "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/kafkajs": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz", + "integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/libsodium": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz", + "integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==", + "dev": true, + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz", + "integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==", + "dev": true, + "license": "ISC", + "dependencies": { + "libsodium": "^0.8.0" + } + }, + "node_modules/license-check-and-add": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/license-check-and-add/-/license-check-and-add-4.0.5.tgz", + "integrity": "sha512-FySnMi3Kf/vO5jka8tcbVF1FhDFb8PWsQ8pg5Y7U/zkQgta+fIrJGcGHO58WFjfKlgvhneG1uQ00Fpxzhau3QA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "fs-extra": "^8.1.0", + "gitignore-to-glob": "^0.3.0", + "globby": "^10.0.1", + "ignore": "^5.1.2", + "yargs": "^13.3.0" + }, + "bin": { + "license-check-and-add": "dist/src/cli.js" + } + }, + "node_modules/license-check-and-add/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/license-check-and-add/node_modules/cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "node_modules/license-check-and-add/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/license-check-and-add/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/license-check-and-add/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true, + "license": "MIT" + }, + "node_modules/license-check-and-add/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/license-check-and-add/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/license-check-and-add/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/license-check-and-add/node_modules/yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/license-check-and-add/node_modules/yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/mdsvex": { - "version": "0.12.7", - "resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.12.7.tgz", - "integrity": "sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.4", - "@types/unist": "^2.0.3", - "prism-svelte": "^0.4.7", - "prismjs": "^1.17.1", - "unist-util-visit": "^2.0.1", - "vfile-message": "^2.0.4" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependencies": { - "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdsvex/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, "license": "MIT" }, - "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", "dev": true, "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, "engines": { - "node": ">= 8" + "node": ">=6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/lockfile-lint": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/lockfile-lint/-/lockfile-lint-5.0.0.tgz", + "integrity": "sha512-QcVIVITLZAhWYHU2wbNSOMgwc6EN4Y2sy6mjgS5aikYyRzgDIfotXUsCrm38En+3fZpc58Yu7DF9dNeT/goi1A==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "cosmiconfig": "^9.0.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "lockfile-lint-api": "^5.9.2", + "yargs": "^17.7.2" + }, + "bin": { + "lockfile-lint": "bin/lockfile-lint.js" }, "engines": { - "node": ">=8.6" + "node": ">=16.0.0" } }, - "node_modules/miniflare": { - "version": "4.20260609.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260609.0.tgz", - "integrity": "sha512-4ZfNh9ACDa/mKKQvTSO2vigyQS2MB7dEU02KRPle4FqL7S6nek+2Fq6WGzazZbt1OORYgb4OGVLnOCx+My2NNA==", + "node_modules/lockfile-lint-api": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/lockfile-lint-api/-/lockfile-lint-api-5.9.2.tgz", + "integrity": "sha512-3QhxWxl3jT9GcMxuCnTsU8Tz5U6U1lKBlKBu2zOYOz/x3ONUoojEtky3uzoaaDgExcLqIX0Aqv2I7TZXE383CQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", - "undici": "7.24.8", - "workerd": "1.20260609.1", - "ws": "8.20.1", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" + "@yarnpkg/parsers": "^3.0.0-rc.48.1", + "debug": "^4.3.4", + "object-hash": "^3.0.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=16.0.0" } }, - "node_modules/miniflare/node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "node_modules/lockfile-lint/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=8" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/lockfile-lint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "color-convert": "^2.0.1" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/mnemonist": { - "version": "0.38.3", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", - "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", + "node_modules/lockfile-lint/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "obliterator": "^1.6.1" - } - }, - "node_modules/moo": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", - "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", - "license": "BSD-3-Clause" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=12" } }, - "node_modules/nearley": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "node_modules/lockfile-lint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" + "color-name": "~1.1.4" }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" + "engines": { + "node": ">=7.0.0" } }, - "node_modules/nearley/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/nise": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.5.tgz", - "integrity": "sha512-SnRDPDBjxZZoU2n0+gzzLtSvo1OZo7j6jnbXsoh3AFxEGhaFU7ZF0TmefuKERq79wxR2U+MPn7ArW+Tl+clC3A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.1.1", - "just-extend": "^6.2.0", - "path-to-regexp": "^8.3.0" - } + "node_modules/lockfile-lint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" }, - "node_modules/nise/node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "node_modules/lockfile-lint/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } + "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/lockfile-lint/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-url": "^5.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": ">=8" } }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "license": "ISC", + "node_modules/lockfile-lint/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" + "ansi-regex": "^5.0.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "node_modules/lockfile-lint/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "node_modules/lockfile-lint/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, "engines": { - "node": ">= 6" + "node": ">=12" } }, - "node_modules/obliterator": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", - "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", + "node_modules/lockfile-lint/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], "license": "MIT" }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { - "wrappy": "1" + "yallist": "^3.0.2" } }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "p-try": "^2.0.0" + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" }, "engines": { - "node": ">=6" + "node": ">= 18" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mdsvex": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.12.7.tgz", + "integrity": "sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^2.0.0" + "@types/mdast": "^4.0.4", + "@types/unist": "^2.0.3", + "prism-svelte": "^0.4.7", + "prismjs": "^1.17.1", + "unist-util-visit": "^2.0.1", + "vfile-message": "^2.0.4" }, - "engines": { - "node": ">=6" + "peerDependencies": { + "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/mdsvex/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 8" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/miniflare": { + "version": "4.20260708.1", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260708.1.tgz", + "integrity": "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==", "dev": true, "license": "MIT", "dependencies": { - "callsites": "^3.0.0" + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260708.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" }, "engines": { - "node": ">=6" + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "dev": true, - "license": "MIT", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mnemonist": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", + "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "obliterator": "^1.6.1" } }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", - "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "engines": { + "node": ">=10" } }, - "node_modules/parse5-parser-stream": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/mutation-server-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mutation-server-protocol/-/mutation-server-protocol-0.4.1.tgz", + "integrity": "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "parse5": "^7.0.0" + "zod": "^4.1.12" }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "engines": { + "node": ">=18" } }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "node_modules/mutation-testing-elements": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.7.3.tgz", + "integrity": "sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "license": "Apache-2.0" + }, + "node_modules/mutation-testing-metrics": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.7.3.tgz", + "integrity": "sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-report-schema": "3.7.3" } }, - "node_modules/path-exists": { + "node_modules/mutation-testing-report-schema": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.7.3.tgz", + "integrity": "sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mute-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=4" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": ">=0.10.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" }, - "engines": { - "node": ">=16 || 14 >=14.18" + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/nise": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.5.tgz", + "integrity": "sha512-SnRDPDBjxZZoU2n0+gzzLtSvo1OZo7j6jnbXsoh3AFxEGhaFU7ZF0TmefuKERq79wxR2U+MPn7ArW+Tl+clC3A==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.1.1", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.3.0" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, - "license": "ISC" + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dev": true, "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, "engines": { - "node": ">=8.6" + "node": "4.x || >=6.0.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "license": "ISC", "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/pretty": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz", - "integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==", + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, "license": "MIT", "dependencies": { - "condense-newlines": "^0.2.1", - "extend-shallow": "^2.0.1", - "js-beautify": "^1.6.12" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/prism-svelte": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/prism-svelte/-/prism-svelte-0.4.7.tgz", - "integrity": "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==", + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, - "license": "MIT" - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "license": "ISC" - }, - "node_modules/protobufjs": { - "version": "8.6.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.2.tgz", - "integrity": "sha512-CCERJxzRvKMeEdJSLwdQf40TXWNPc8M4RkN7j/lxY6FQB+4do8rETWqj60AqxP9n0XIsxnSefZ8uhAaGKg2njw==", + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, - "license": "BSD-3-Clause", + "license": "BSD-2-Clause", "dependencies": { - "long": "^5.3.2" + "boolbase": "^1.0.0" }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">= 6" } }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obliterator": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", + "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], "license": "MIT" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" ], - "license": "MIT" - }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", - "license": "CC0-1.0" - }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", "license": "MIT", - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, "engines": { - "node": ">=0.12" + "node": ">=12.20.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" + "wrappy": "1" } }, - "node_modules/redos-detector": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/redos-detector/-/redos-detector-6.1.4.tgz", - "integrity": "sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==", + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { - "regjsparser": "0.13.0" - }, - "bin": { - "redos-detector": "bin.js" + "p-try": "^2.0.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/regexparam": { + "node_modules/p-locate": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", - "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", - "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~3.1.0" + "p-limit": "^2.0.0" }, - "bin": { - "regjsparser": "bin/parser" + "engines": { + "node": ">=6" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=0.12" + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", "dev": true, "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@oxc-project/types": "=0.132.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" + "parse5": "^7.0.0" }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=0.12" }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" + "engines": { + "node": ">=4" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/sast-json-schema": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/sast-json-schema/-/sast-json-schema-0.4.1.tgz", - "integrity": "sha512-GnOPf8rCTfysRtOzCJPoBZu8xKnY4LZ24jxCmcxsLUkmOTpK/DrbrJ97Xio4xkIH6USdzEZ5ZSdpTX8Gj03EuQ==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", - "workspaces": [ - ".github" - ], + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", "dependencies": { - "ajv": "8.20.0", - "redos-detector": "6.1.4" - }, - "bin": { - "sast-json-schema": "cli.js" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=24" + "node": ">=16 || 14 >=14.18" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/willfarrell" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/seedrandom": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/set-cookie-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", - "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=8.6" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, - "node_modules/shebang-command": { + "node_modules/pretty": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz", + "integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==", "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "condense-newlines": "^0.2.1", + "extend-shallow": "^2.0.1", + "js-beautify": "^1.6.12" }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prism-svelte": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/prism-svelte/-/prism-svelte-0.4.7.tgz", + "integrity": "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=0.4.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, + "node_modules/protobufjs": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.0.tgz", + "integrity": "sha512-uu52JNxLh3vsL7tXU/h0gDaywufvuUCTbGSi0NKQKBZ2ZopkmrWQJSQO/EFqzu/5YhiwgVM8rq/a/iVpx4eZ0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=12.0.0" } }, - "node_modules/sinon": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.1.tgz", - "integrity": "sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==", + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "11.2.2", - "@sinonjs/samsam": "^8.0.0", - "diff": "^5.2.0", - "nise": "^6.0.0", - "supports-color": "^7" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/sinon" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", "license": "MIT", "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" }, "engines": { - "node": ">=18" + "node": ">=0.12" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/smtp-address-parser": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.1.0.tgz", - "integrity": "sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==", + "node_modules/redos-detector": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/redos-detector/-/redos-detector-6.1.4.tgz", + "integrity": "sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==", "license": "MIT", "dependencies": { - "nearley": "^2.20.1" + "regjsparser": "0.13.0" + }, + "bin": { + "redos-detector": "bin.js" }, "engines": { - "node": ">=0.10" + "node": ">=16.0.0" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stream-browserify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", - "dev": true, - "license": "MIT", + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "license": "BSD-2-Clause", "dependencies": { - "inherits": "~2.0.4", - "readable-stream": "^3.5.0" + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=0.12" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { - "node": ">=8" + "iojs": ">=1.0.0", + "node": ">=0.10.0" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "queue-microtask": "^1.2.2" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sast-json-schema": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/sast-json-schema/-/sast-json-schema-0.4.1.tgz", + "integrity": "sha512-GnOPf8rCTfysRtOzCJPoBZu8xKnY4LZ24jxCmcxsLUkmOTpK/DrbrJ97Xio4xkIH6USdzEZ5ZSdpTX8Gj03EuQ==", "license": "MIT", + "workspaces": [ + ".github" + ], "dependencies": { - "has-flag": "^4.0.0" + "ajv": "8.20.0", + "redos-detector": "6.1.4" + }, + "bin": { + "sast-json-schema": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=24" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/willfarrell" } }, - "node_modules/svelte": { - "version": "5.55.9", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", - "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.9", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" + "node": ">=11.0.0" } }, - "node_modules/svgo": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", - "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^11.1.0", - "css-select": "^5.1.0", - "css-tree": "^3.0.1", - "css-what": "^6.1.0", - "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "^1.5.0" - }, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", "bin": { - "svgo": "bin/svgo.js" + "semver": "bin/semver.js" }, "engines": { - "node": ">=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" + "node": ">=10" } }, - "node_modules/svgo/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true, - "license": "MIT", + "license": "ISC" + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, "engines": { - "node": ">=16" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.0.2.tgz", - "integrity": "sha512-FlHoQpcFvCzeXK5kVPvV7IVgW/hs/B36QWTz876iSdeJguBDfdTSRQmYmaHX+fQNt4hp+gEFB2XXw+8hT4/y8A==", - "dev": true, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, "engines": { - "node": ">=20.0.0" + "node": ">=8" } }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { - "node": ">=12.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=12.0.0" + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "engines": { + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tinykeys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinykeys/-/tinykeys-3.0.0.tgz", - "integrity": "sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==", - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { - "node": ">=8.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", "engines": { - "node": ">=6" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/sinon": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.1.tgz", + "integrity": "sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==", "dev": true, - "license": "0BSD" + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } }, - "node_modules/tstyche": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/tstyche/-/tstyche-7.2.1.tgz", - "integrity": "sha512-XMaaGk8Mrv+LMULdaK2H8jix1dLvKAi9lO/HTZZuK/tV0H3pD/9nCuCTIsHotpZ3VScOzv8ZGT2/6UvdSxzHgw==", + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, "license": "MIT", - "bin": { - "tstyche": "dist/bin.js" + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" }, "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/tstyche/tstyche?sponsor=1" - }, - "peerDependencies": { - "typescript": ">=5.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=18" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/typed-function": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", - "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", - "dev": true, + "node_modules/smtp-address-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.1.0.tgz", + "integrity": "sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==", "license": "MIT", + "dependencies": { + "nearley": "^2.20.1" + }, "engines": { - "node": ">= 18" + "node": ">=0.10" } }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=14.17" + "node": ">= 12" } }, - "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { - "node": ">=22.19.0" + "node": ">=0.10.0" } }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", "dependencies": { - "pathe": "^2.0.3" + "safe-buffer": "~5.2.0" } }, - "node_modules/unist-util-is": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", - "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/unist-util-stringify-position": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", - "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", - "dev": true, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.2" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=8" } }, - "node_modules/unist-util-stringify-position/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "dev": true, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/unist-util-visit": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", - "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", - "dev": true, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^4.0.0", - "unist-util-visit-parents": "^3.0.0" + "ansi-regex": "^5.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=8" } }, - "node_modules/unist-util-visit-parents": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", - "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", - "dev": true, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^4.0.0" + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/unist-util-visit-parents/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "dev": true, - "license": "MIT" - }, - "node_modules/unist-util-visit/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "dev": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">= 4.0.0" + "node": ">=8" } }, - "node_modules/uri-js-replace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", - "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", - "license": "MIT" + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, - "license": "MIT" - }, - "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/vfile-message": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", - "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", - "dev": true, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^2.0.0" + "has-flag": "^4.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=8" } }, - "node_modules/vfile-message/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "dev": true, - "license": "MIT" + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } }, - "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" }, "bin": { - "vite": "bin/vite.js" + "svgo": "bin/svgo.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=16" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "type": "opencollective", + "url": "https://opencollective.com/svgo" } }, - "node_modules/vite-plugin-llms": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/vite-plugin-llms/-/vite-plugin-llms-1.0.2.tgz", - "integrity": "sha512-MLO/9nUSpwCKkjip88cseLihIxhOpHc3GzOrNfbUPS+xp7hbBfPF0LgauyYfTJfPT/GcCPVEqrsy3MQ9JUv+MQ==", + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "dev": true, "license": "MIT", - "peerDependencies": { - "vite": ">=2.0.0" + "engines": { + "node": ">=16" } }, - "node_modules/vite-plugin-mkcert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-plugin-mkcert/-/vite-plugin-mkcert-2.1.0.tgz", - "integrity": "sha512-UQS5iyknruwCsvqDPsKRyHIVSVfv/iGdRPmUPaM1JRwNJo8wo/MubzJ66ckWwOLxFU4wW90oZq9GviV87nRHsg==", - "dev": true, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", "license": "MIT", "dependencies": { - "debug": "^4.4.3", - "supports-color": "^10.2.2", - "undici": "^8.3.0" + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" }, "engines": { - "node": ">=22.19.0" - }, - "peerDependencies": { - "vite": ">=3" + "node": ">=12.17" } }, - "node_modules/vite-plugin-mkcert/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.0.2.tgz", + "integrity": "sha512-FlHoQpcFvCzeXK5kVPvV7IVgW/hs/B36QWTz876iSdeJguBDfdTSRQmYmaHX+fQNt4hp+gEFB2XXw+8hT4/y8A==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=20.0.0" } }, - "node_modules/vite-plugin-sitemap": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/vite-plugin-sitemap/-/vite-plugin-sitemap-0.8.2.tgz", - "integrity": "sha512-bqIw6NVOXg6je81lzX8Lm0vjf8/QSAp8di8fYQzZ3ZdVicOm8+6idBGALJiy1R1FiXNIK8rgORO6HBqXyHW+iQ==", - "dev": true - }, - "node_modules/vite-plugin-sri": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/vite-plugin-sri/-/vite-plugin-sri-0.0.2.tgz", - "integrity": "sha512-oTpYWvS9xmwee3kK39cr8p2KbQ+/HzOG0bxo0dzMogJ4lR11favOzrapwb2eASVt5rX3LEzG25bEqoSY4CUniA==", + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, - "license": "ISC", - "dependencies": { - "cheerio": "^1.0.0-rc.5", - "node-fetch": "^2.6.1" + "license": "MIT", + "engines": { + "node": ">=18" } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=12" + "node": ">=12.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + "picomatch": "^3 || ^4" }, "peerDependenciesMeta": { - "vite": { + "picomatch": { "optional": true } } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinykeys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tinykeys/-/tinykeys-4.0.0.tgz", + "integrity": "sha512-z5bQRQHL1PSx3lGOZEAMM2oKp0EBtn5T5f7HJnaKPOzk6vEH0wUPvdHLeQ7F4tmkek8AgoTQISUFmn/5SNF2xA==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" } }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "is-number": "^7.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=8.0" } }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "bin": { + "tree-kill": "cli.js" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tstyche": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/tstyche/-/tstyche-7.2.1.tgz", + "integrity": "sha512-XMaaGk8Mrv+LMULdaK2H8jix1dLvKAi9lO/HTZZuK/tV0H3pD/9nCuCTIsHotpZ3VScOzv8ZGT2/6UvdSxzHgw==", + "dev": true, + "license": "MIT", "bin": { - "node-which": "bin/node-which" + "tstyche": "dist/bin.js" }, "engines": { - "node": ">= 8" + "node": ">=22" + }, + "funding": { + "url": "https://github.com/tstyche/tstyche?sponsor=1" + }, + "peerDependencies": { + "typescript": ">=5.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } }, - "node_modules/workerd": { - "version": "1.20260609.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260609.1.tgz", - "integrity": "sha512-KF/Y/8f4VoXCk87NuU6RqmO0X5fdzcrxU3XzAgoPUpnH9t1ZyzRgX1O/9sJvjItxroCBTEBzKssda02Dz9i6BA==", + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, + "license": "MIT", "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260609.1", - "@cloudflare/workerd-darwin-arm64": "1.20260609.1", - "@cloudflare/workerd-linux-64": "1.20260609.1", - "@cloudflare/workerd-linux-arm64": "1.20260609.1", - "@cloudflare/workerd-windows-64": "1.20260609.1" + "node": ">=4" } }, - "node_modules/worktop": { - "version": "0.8.0-next.18", - "resolved": "https://registry.npmjs.org/worktop/-/worktop-0.8.0-next.18.tgz", - "integrity": "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==", + "node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", "dev": true, "license": "MIT", - "dependencies": { - "mrmime": "^2.0.0", - "regexparam": "^3.0.0" - }, "engines": { - "node": ">=12" + "node": ">= 18" } }, - "node_modules/wrangler": { - "version": "4.99.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.99.0.tgz", - "integrity": "sha512-i7GA2mZETTyq3ljWdEzM908FjLaMWZ1AaAHKaOJ8pFA/tonf2VqIWDyBGzKleIVBbNQxOTIY2wnbv0iaK3rC6g==", + "node_modules/typed-inject": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", + "integrity": "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==", "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260609.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260609.1" - }, - "bin": { - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, + "license": "Apache-2.0", "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260609.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } + "node": ">=18" } }, - "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], + "node_modules/typed-rest-client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.3.1.tgz", + "integrity": "sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "6.15.1", + "tunnel": "0.0.6", + "underscore": "^1.13.8" + }, "engines": { - "node": ">=18" + "node": ">= 16.0.0" } }, - "node_modules/wrangler/node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=12.17" } }, - "node_modules/wrangler/node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.7.0.tgz", + "integrity": "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=22.19.0" } }, - "node_modules/wrangler/node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "pathe": "^2.0.3" } }, - "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], + "node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], + "node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], + "node_modules/unist-util-stringify-position/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], + "node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], + "node_modules/unist-util-visit-parents/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unist-util-visit/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">= 4.0.0" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], - "engines": { - "node": ">=18" + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], + "node_modules/vfile-message/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], + "node_modules/vite-plugin-llms": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/vite-plugin-llms/-/vite-plugin-llms-1.0.2.tgz", + "integrity": "sha512-MLO/9nUSpwCKkjip88cseLihIxhOpHc3GzOrNfbUPS+xp7hbBfPF0LgauyYfTJfPT/GcCPVEqrsy3MQ9JUv+MQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "peerDependencies": { + "vite": ">=2.0.0" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], + "node_modules/vite-plugin-mkcert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-mkcert/-/vite-plugin-mkcert-2.1.0.tgz", + "integrity": "sha512-UQS5iyknruwCsvqDPsKRyHIVSVfv/iGdRPmUPaM1JRwNJo8wo/MubzJ66ckWwOLxFU4wW90oZq9GviV87nRHsg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "debug": "^4.4.3", + "supports-color": "^10.2.2", + "undici": "^8.3.0" + }, "engines": { - "node": ">=18" + "node": ">=22.19.0" + }, + "peerDependencies": { + "vite": ">=3" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], + "node_modules/vite-plugin-mkcert/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], + "node_modules/vite-plugin-sitemap": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/vite-plugin-sitemap/-/vite-plugin-sitemap-0.8.2.tgz", + "integrity": "sha512-bqIw6NVOXg6je81lzX8Lm0vjf8/QSAp8di8fYQzZ3ZdVicOm8+6idBGALJiy1R1FiXNIK8rgORO6HBqXyHW+iQ==", + "dev": true + }, + "node_modules/vite-plugin-sri": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/vite-plugin-sri/-/vite-plugin-sri-0.0.2.tgz", + "integrity": "sha512-oTpYWvS9xmwee3kK39cr8p2KbQ+/HzOG0bxo0dzMogJ4lR11favOzrapwb2eASVt5rX3LEzG25bEqoSY4CUniA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "license": "ISC", + "dependencies": { + "cheerio": "^1.0.0-rc.5", + "node-fetch": "^2.6.1" } }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" ], - "engines": { - "node": ">=18" + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } } }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], + "node_modules/weapon-regex": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-1.3.6.tgz", + "integrity": "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } + "license": "Apache-2.0" }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } + "license": "BSD-2-Clause" }, - "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "iconv-lite": "0.6.3" + }, "engines": { "node": ">=18" } }, - "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">=18" } }, - "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, "engines": { - "node": ">=18" + "node": ">= 8" } }, - "node_modules/wrangler/node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "dev": true, + "license": "ISC" + }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=12.17" } }, - "node_modules/wrangler/node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "node_modules/workerd": { + "version": "1.20260708.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260708.1.tgz", + "integrity": "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==", "dev": true, "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260708.1", + "@cloudflare/workerd-darwin-arm64": "1.20260708.1", + "@cloudflare/workerd-linux-64": "1.20260708.1", + "@cloudflare/workerd-linux-arm64": "1.20260708.1", + "@cloudflare/workerd-windows-64": "1.20260708.1" + } + }, + "node_modules/worktop": { + "version": "0.8.0-next.18", + "resolved": "https://registry.npmjs.org/worktop/-/worktop-0.8.0-next.18.tgz", + "integrity": "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==", + "dev": true, "license": "MIT", + "dependencies": { + "mrmime": "^2.0.0", + "regexparam": "^3.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler": { + "version": "4.110.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.110.0.tgz", + "integrity": "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260708.1", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260708.1" + }, "bin": { - "esbuild": "bin/esbuild" + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=18" + "node": ">=22.0.0" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260708.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } } }, "node_modules/wrangler/node_modules/path-to-regexp": { @@ -8818,9 +11008,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -8849,6 +11039,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", @@ -8877,6 +11074,19 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", @@ -8902,67 +11112,70 @@ "error-stack-parser-es": "^1.0.5" } }, - "node_modules/youch/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" }, - "node_modules/zstd-codec": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", - "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/arrow": { + "name": "@datastream/arrow", + "version": "0.6.1", + "license": "MIT", + "dependencies": { + "@datastream/core": "0.6.1", + "apache-arrow": "21.1.0" + }, + "engines": { + "node": ">=24" + } }, "packages/aws": { "name": "@datastream/aws", - "version": "0.5.0", + "version": "0.6.1", "license": "MIT", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" }, "devDependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.1065.0", - "@aws-sdk/client-dynamodb": "^3.1065.0", - "@aws-sdk/client-dynamodb-streams": "^3.1065.0", - "@aws-sdk/client-kinesis": "^3.1065.0", - "@aws-sdk/client-lambda": "^3.1065.0", - "@aws-sdk/client-s3": "^3.1065.0", - "@aws-sdk/client-sns": "^3.1065.0", - "@aws-sdk/client-sqs": "^3.1065.0", - "@aws-sdk/client-ssm": "^3.1065.0", - "@aws-sdk/lib-storage": "^3.1065.0", + "@aws-sdk/client-cloudwatch-logs": "^3.0.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/client-dynamodb-streams": "^3.0.0", + "@aws-sdk/client-glue": "^3.0.0", + "@aws-sdk/client-kinesis": "^3.0.0", + "@aws-sdk/client-lambda": "^3.0.0", + "@aws-sdk/client-s3": "^3.0.0", + "@aws-sdk/client-sns": "^3.0.0", + "@aws-sdk/client-sqs": "^3.0.0", + "@aws-sdk/lib-storage": "^3.0.0", + "aws-msk-iam-sasl-signer-js": "^1.0.0", "aws-sdk-client-mock": "^4.0.0" }, "engines": { "node": ">=24" }, "peerDependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.1065.0", - "@aws-sdk/client-dynamodb": "^3.1065.0", - "@aws-sdk/client-dynamodb-streams": "^3.1065.0", - "@aws-sdk/client-kinesis": "^3.1065.0", - "@aws-sdk/client-lambda": "^3.1065.0", - "@aws-sdk/client-s3": "^3.1065.0", - "@aws-sdk/client-sns": "^3.1065.0", - "@aws-sdk/client-sqs": "^3.1065.0", - "@aws-sdk/client-ssm": "^3.1065.0", - "@aws-sdk/lib-storage": "^3.1065.0" + "@aws-sdk/client-cloudwatch-logs": "^3.0.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/client-dynamodb-streams": "^3.0.0", + "@aws-sdk/client-glue": "^3.0.0", + "@aws-sdk/client-kinesis": "^3.0.0", + "@aws-sdk/client-lambda": "^3.0.0", + "@aws-sdk/client-s3": "^3.0.0", + "@aws-sdk/client-sns": "^3.0.0", + "@aws-sdk/client-sqs": "^3.0.0", + "@aws-sdk/lib-storage": "^3.0.0", + "aws-msk-iam-sasl-signer-js": "^1.0.0" }, "peerDependenciesMeta": { "@aws-sdk/client-cloudwatch-logs": { @@ -8974,6 +11187,9 @@ "@aws-sdk/client-dynamodb-streams": { "optional": true }, + "@aws-sdk/client-glue": { + "optional": true + }, "@aws-sdk/client-kinesis": { "optional": true }, @@ -8989,716 +11205,412 @@ "@aws-sdk/client-sqs": { "optional": true }, - "@aws-sdk/client-ssm": { - "optional": true - }, "@aws-sdk/lib-storage": { "optional": true - } - } - }, - "packages/base64": { - "name": "@datastream/base64", - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0" - }, - "engines": { - "node": ">=24" - } - }, - "packages/charset": { - "name": "@datastream/charset", - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0", - "chardet": "2.1.1", - "iconv-lite": "0.7.2" - }, - "engines": { - "node": ">=24" - } - }, - "packages/compress": { - "name": "@datastream/compress", - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0" - }, - "devDependencies": { - "brotli-wasm": "^3.0.0", - "protobufjs": "^8.6.2", - "zstd-codec": "^0.1.0" - }, - "engines": { - "node": ">=24" - }, - "peerDependencies": { - "brotli-wasm": "^3.0.0", - "protobufjs": "^8.6.2", - "zstd-codec": "^0.1.0" - }, - "peerDependenciesMeta": { - "brotli-wasm": { - "optional": true - }, - "protobufjs": { - "optional": true }, - "zstd-codec": { - "optional": true - } - } - }, - "packages/core": { - "name": "@datastream/core", - "version": "0.5.0", - "license": "MIT", - "devDependencies": { - "@datastream/object": "0.5.0" - }, - "engines": { - "node": ">=24" - } - }, - "packages/csv": { - "name": "@datastream/csv", - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0", - "@datastream/object": "0.5.0" - }, - "engines": { - "node": ">=24" - } - }, - "packages/digest": { - "name": "@datastream/digest", - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0", - "hash-wasm": "4.12.0" - }, - "engines": { - "node": ">=24" - } - }, - "packages/encrypt": { - "name": "@datastream/encrypt", - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0" - }, - "devDependencies": { - "libsodium-wrappers": ">=0.7.0" - }, - "engines": { - "node": ">=24" - }, - "peerDependencies": { - "libsodium-wrappers": ">=0.7.0" - }, - "peerDependenciesMeta": { - "libsodium-wrappers": { + "aws-msk-iam-sasl-signer-js": { "optional": true } } - }, - "packages/fetch": { - "name": "@datastream/fetch", - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0" - }, - "engines": { - "node": ">=24" - } - }, - "packages/file": { - "name": "@datastream/file", - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0" - }, - "engines": { - "node": ">=24" - } - }, - "packages/indexeddb": { - "name": "@datastream/indexeddb", - "version": "0.5.0", + }, + "packages/base64": { + "name": "@datastream/base64", + "version": "0.6.1", "license": "MIT", "dependencies": { - "@datastream/core": "0.5.0", - "idb": "8.0.3" + "@datastream/core": "0.6.1" }, "engines": { "node": ">=24" } }, - "packages/ipfs": { - "name": "@datastream/ipfs", - "version": "0.5.0", + "packages/charset": { + "name": "@datastream/charset", + "version": "0.6.1", "license": "MIT", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1", + "chardet": "2.2.0", + "iconv-lite": "0.7.3" }, "engines": { "node": ">=24" } }, - "packages/json": { - "name": "@datastream/json", - "version": "0.5.0", + "packages/compress": { + "name": "@datastream/compress", + "version": "0.6.1", "license": "MIT", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" + }, + "devDependencies": { + "brotli-wasm": "^3.0.0" }, "engines": { "node": ">=24" + }, + "peerDependencies": { + "brotli-wasm": "^3.0.0" + }, + "peerDependenciesMeta": { + "brotli-wasm": { + "optional": true + } } }, - "packages/object": { - "name": "@datastream/object", - "version": "0.5.0", + "packages/core": { + "name": "@datastream/core", + "version": "0.6.1", "license": "MIT", - "dependencies": { - "@datastream/core": "0.5.0" + "devDependencies": { + "@datastream/object": "0.6.1" }, "engines": { "node": ">=24" } }, - "packages/string": { - "name": "@datastream/string", - "version": "0.5.0", + "packages/csv": { + "name": "@datastream/csv", + "version": "0.6.1", "license": "MIT", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1", + "@datastream/object": "0.6.1" }, "engines": { "node": ">=24" } }, - "packages/validate": { - "name": "@datastream/validate", - "version": "0.5.0", + "packages/digest": { + "name": "@datastream/digest", + "version": "0.6.1", "license": "MIT", "dependencies": { - "@datastream/core": "0.5.0", - "ajv-cmd": "0.13.3" + "@datastream/core": "0.6.1", + "hash-wasm": "4.12.0" }, "engines": { "node": ">=24" } }, - "websites/datastream.js.org": { - "version": "0.5.0", + "packages/duckdb": { + "name": "@datastream/duckdb", + "version": "0.6.1", + "license": "MIT", "dependencies": { - "@plausible-analytics/tracker": "0.4.5", - "@willfarrell-ds/svelte": "0.0.0-alpha.6", - "@willfarrell-ds/vanilla": "0.0.0-alpha.6", - "hast-util-to-string": "3.0.1", - "mdast-util-to-string": "4.0.0", - "uuid": "14.0.0" + "@datastream/core": "0.6.1" }, "devDependencies": { - "@sveltejs/adapter-cloudflare": "^7.0.0", - "@sveltejs/kit": "^2.64.0", - "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@willfarrell-ds/cli": "0.0.0-alpha.6", - "esbuild": "^0.28.1", - "mdsvex": "^0.12.0", - "svelte": "^5.56.3", - "svgo": "^4.0.0", - "vite": "^8.0.16", - "vite-plugin-llms": "^1.0.0", - "vite-plugin-mkcert": "^2.1.0", - "vite-plugin-sitemap": "^0.8.0", - "vite-plugin-sri": "^0.0.2", - "wrangler": "^4.99.0" - } - }, - "websites/datastream.js.org/node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "websites/datastream.js.org/node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "websites/datastream.js.org/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "websites/datastream.js.org/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "websites/datastream.js.org/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "websites/datastream.js.org/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "websites/datastream.js.org/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "websites/datastream.js.org/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "@datastream/arrow": "0.6.1", + "@duckdb/duckdb-wasm": "1.33.1-dev57.0", + "@duckdb/node-api": "1.5.4-r.1", + "apache-arrow": "21.1.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" + }, + "peerDependencies": { + "@duckdb/duckdb-wasm": "^1.33.1-dev57.0", + "@duckdb/node-api": "^1.5.4-r.1", + "apache-arrow": "21.1.0" + }, + "peerDependenciesMeta": { + "@duckdb/duckdb-wasm": { + "optional": true + }, + "@duckdb/node-api": { + "optional": true + }, + "apache-arrow": { + "optional": true + } } }, - "websites/datastream.js.org/node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], + "packages/encrypt": { + "name": "@datastream/encrypt", + "version": "0.6.1", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@datastream/core": "0.6.1" + }, + "devDependencies": { + "libsodium-wrappers": ">=0.7.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" + }, + "peerDependencies": { + "libsodium-wrappers": ">=0.7.0" + }, + "peerDependenciesMeta": { + "libsodium-wrappers": { + "optional": true + } } }, - "websites/datastream.js.org/node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], + "packages/fetch": { + "name": "@datastream/fetch", + "version": "0.6.1", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@datastream/core": "0.6.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" } }, - "websites/datastream.js.org/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], + "packages/file": { + "name": "@datastream/file", + "version": "0.6.1", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@datastream/core": "0.6.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" } }, - "websites/datastream.js.org/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], + "packages/indexeddb": { + "name": "@datastream/indexeddb", + "version": "0.6.1", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@datastream/core": "0.6.1", + "idb": "8.0.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" } }, - "websites/datastream.js.org/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", - "cpu": [ - "arm64" - ], - "dev": true, + "packages/ipfs": { + "name": "@datastream/ipfs", + "version": "0.6.1", "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@datastream/core": "0.6.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" } }, - "websites/datastream.js.org/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, + "packages/json": { + "name": "@datastream/json", + "version": "0.6.1", "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@datastream/core": "0.6.1" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" } }, - "websites/datastream.js.org/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", - "cpu": [ - "arm64" - ], - "dev": true, + "packages/kafka": { + "name": "@datastream/kafka", + "version": "0.6.1", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@datastream/core": "0.6.1" + }, + "devDependencies": { + "kafkajs": "^2.0.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" + }, + "peerDependencies": { + "kafkajs": "^2.0.0" + }, + "peerDependenciesMeta": { + "kafkajs": { + "optional": true + } } }, - "websites/datastream.js.org/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", - "cpu": [ - "x64" - ], - "dev": true, + "packages/object": { + "name": "@datastream/object", + "version": "0.6.1", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@datastream/core": "0.6.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=24" } }, - "websites/datastream.js.org/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "packages/protobuf": { + "name": "@datastream/protobuf", + "version": "0.6.1", "license": "MIT", + "dependencies": { + "@datastream/core": "0.6.1" + }, + "devDependencies": { + "protobufjs": "^8.0.0" + }, "engines": { - "node": ">=12.0.0" + "node": ">=24" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "protobufjs": "^8.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "protobufjs": { "optional": true } } }, - "websites/datastream.js.org/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, + "packages/schema-registry": { + "name": "@datastream/schema-registry", + "version": "0.6.1", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@datastream/core": "0.6.1" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=24" } }, - "websites/datastream.js.org/node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", - "dev": true, + "packages/string": { + "name": "@datastream/string", + "version": "0.6.1", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" + "@datastream/core": "0.6.1" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" - } - }, - "websites/datastream.js.org/node_modules/svelte": { - "version": "5.56.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", - "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", - "dev": true, + "node": ">=24" + } + }, + "packages/validate": { + "name": "@datastream/validate", + "version": "0.6.1", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.11", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" + "@datastream/core": "0.6.1", + "ajv-cmd": "0.13.3" }, "engines": { - "node": ">=18" + "node": ">=24" } }, - "websites/datastream.js.org/node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", + "websites/datastream.js.org": { + "version": "0.6.1", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "@plausible-analytics/tracker": "0.4.5", + "@willfarrell-ds/svelte": "0.0.0-alpha.8", + "@willfarrell-ds/vanilla": "0.0.0-alpha.8", + "hast-util-to-string": "3.0.1", + "mdast-util-to-string": "4.0.0", + "uuid": "14.0.1" + }, + "devDependencies": { + "@sveltejs/adapter-cloudflare": "^7.0.0", + "@sveltejs/kit": "^2.60.1", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@willfarrell-ds/cli": "0.0.0-alpha.8", + "esbuild": "^0.28.0", + "mdsvex": "^0.12.0", + "svelte": "^5.55.7", + "svgo": "^4.0.0", + "vite": "^8.0.12", + "vite-plugin-llms": "^1.0.0", + "vite-plugin-mkcert": "^2.0.0", + "vite-plugin-sitemap": "^0.8.0", + "vite-plugin-sri": "^0.0.2", + "wrangler": "^4.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=24" + } + }, + "websites/datastream.js.org/node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "websites/datastream.js.org/node_modules/@sveltejs/adapter-cloudflare": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-cloudflare/-/adapter-cloudflare-7.2.9.tgz", + "integrity": "sha512-LEfLRYKZIiNYBPYk9Cu4dFCCIX37NO96jESNRDv7yr3vvblKU4Yh05FPUMRSrOWFKtG6bJcGIvdG/M2DSf4D1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cloudflare/workers-types": "^4.20250507.0", + "worktop": "0.8.0-next.18" }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "peerDependencies": { + "@sveltejs/kit": "^2.0.0", + "wrangler": "^4.0.0" } }, - "websites/datastream.js.org/node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "websites/datastream.js.org/node_modules/@sveltejs/kit": { + "version": "2.69.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.2.tgz", + "integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", - "tinyglobby": "^0.2.17" + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" }, "bin": { - "vite": "bin/vite.js" + "svelte-kit": "svelte-kit.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "node": ">=18.13" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { + "@opentelemetry/api": { "optional": true }, - "yaml": { + "typescript": { "optional": true } } + }, + "websites/datastream.js.org/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "websites/datastream.js.org/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "extraneous": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/package.json b/package.json index 17fb300..5101220 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/monorepo", - "version": "0.5.0", + "version": "0.6.1", "description": "Streams made easy.", "private": true, "type": "module", @@ -10,35 +10,41 @@ "engineStrict": true, "scripts": { "prepare": "husky || true", - "git:pre-commit": "npm run git:lint-staged && npm run git:test-staged", + "git:pre-commit": "npm run git:lint-staged", + "git:pre-push": "npm run git:test-staged", "git:commit-msg": "commitlint --config commitlint.config.cjs --edit", "git:lint-staged": "npm run test:lint:staged", "git:test-staged": "which node && node -v && npm run test:unit", "lint": "biome check --write --no-errors-on-unmatched", "build": "bin/esbuild", "pretest": "npm run build", - "test": "npm run test:lint && npm run test:unit && npm run test:types && npm run test:sast && npm run test:perf && npm run test:dast", + "test": "npm run test:lint && npm run test:unit && npm run test:mutation && npm run test:dast && npm run test:types && npm run test:perf && npm run test:sast", "test:lint": "biome ci --no-errors-on-unmatched", "test:lint:staged": "biome check --staged --no-errors-on-unmatched", "test:unit": "npm run test:unit:node && npm run test:unit:web", - "test:unit:node": "node --test --conditions=node --test-force-exit --experimental-test-coverage --test-coverage-exclude=packages/file/** --test-coverage-exclude=**/*.test.js --test-coverage-exclude=**/*.fuzz.js --test-coverage-exclude=**/*.perf.js --test-coverage-lines=96 --test-coverage-branches=93 --test-coverage-functions=95", - "test:unit:web": "node --test --conditions=webstream --experimental-test-coverage --test-coverage-exclude=packages/file/** --test-coverage-exclude=**/*.test.js --test-coverage-exclude=**/*.fuzz.js --test-coverage-exclude=**/*.perf.js --test-coverage-lines=95 --test-coverage-branches=93 --test-coverage-functions=95", - "test:sast": "npm run test:sast:license && npm run test:sast:lockfile && npm run test:sast:semgrep && npm run test:sast:trufflehog && npm run test:sast:trivy && npm run test:sast:ort", + "test:unit:node": "node --test --conditions=node --test-force-exit --experimental-test-coverage --test-coverage-exclude=packages/file/** --test-coverage-exclude=**/*.web.js --test-coverage-exclude=**/*.web.mjs --test-coverage-exclude=**/*.test.js --test-coverage-exclude=**/*.fuzz.js --test-coverage-exclude=**/*.perf.js --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100", + "test:unit:web": "node --test --conditions=webstream --experimental-test-coverage --test-coverage-exclude=packages/file/** --test-coverage-exclude=**/*.web.js --test-coverage-exclude=**/*.web.mjs --test-coverage-exclude=**/*.test.js --test-coverage-exclude=**/*.fuzz.js --test-coverage-exclude=**/*.perf.js --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100", + "test:sast": "npm run test:sast:license && npm run test:sast:lockfile && npm run test:sast:semgrep && npm run test:sast:trufflehog && npm run test:sast:gitleaks && npm run test:sast:actionlint && npm run test:sast:zizmor && npm run test:sast:trivy", + "test:sast:actionlint": "actionlint", + "test:sast:gitleaks": "npm run test:sast:gitleaks:dir && npm run test:sast:gitleaks:git", + "test:sast:gitleaks:dir": "gitleaks dir . --redact --no-banner", + "test:sast:gitleaks:git": "gitleaks git . --redact --no-banner", "test:sast:license": "license-check-and-add check -f .license.config.json", "test:sast:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https", - "test:sast:ort": "docker run ghcr.io/oss-review-toolkit/ort --help", - "test:sast:semgrep": "semgrep scan --config auto", - "test:sast:trivy": "trivy fs --scanners vuln,license --include-dev-deps --ignored-licenses 0BSD,Apache-2.0,BSD-1-Clause,BSD-2-Clause,BSD-3-Clause,CC0-1.0,CC-BY-4.0,ISC,MIT,Python-2.0 --exit-code 1 --disable-telemetry .", + "test:sast:semgrep": "semgrep scan --config auto --error", + "test:sast:trivy": "trivy fs --scanners vuln,license --include-dev-deps --ignored-licenses 0BSD,Apache-2.0,BSD-1-Clause,BSD-2-Clause,BSD-3-Clause,CC0-1.0,CC-BY-4.0,ISC,MIT,MPL-2.0,BlueOak-1.0.0,Unlicense,Python-2.0,LGPL-3.0-or-later --exit-code 1 --skip-files '**/bun.lock' --disable-telemetry .", "test:sast:trufflehog": "trufflehog filesystem --only-verified --log-level=-1 ./", + "test:sast:zizmor": "zizmor .github", "test:types": "tstyche", - "test:perf": "node --test --test-concurrency ./**/*.perf.js", + "test:mutation": "npm run test:mutation --workspaces --if-present", + "test:perf": "node --test --test-concurrency=1 'packages/**/*.perf.js'", "test:dast": "npm run test:dast:fuzz", - "test:dast:fuzz": "node --test ./**/*.fuzz.js", + "test:dast:fuzz": "node --test 'packages/**/*.fuzz.js'", "rm": "npm run rm:macos && npm run rm:node_modules && npm run rm:lock", - "rm:macos": "find . -name '.DS_Store' -type f -prune -exec rm -rf '{}' +", - "rm:lock": "find . -name 'package-lock.json' -type f -prune -exec rm -rf '{}' +", + "rm:macos": "find . -name '.DS_Store' -type f -delete", + "rm:lock": "find . -name 'package-lock.json' -type f -delete", "rm:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +", - "update": "npm update --workspaces && npm install --workspaces", + "update": "corepack up && npm run rm && npm install --workspaces", "outdated": "npm outdated --workspaces", "audit": "npm audit fix --workspaces", "release:license:add": "license-check-and-add add -f .license.config.json", @@ -66,17 +72,19 @@ }, "homepage": "https://datastream.js.org", "devDependencies": { - "@biomejs/biome": "^2.4.16", - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2", - "@types/node": "^25.9.2", - "esbuild": "^0.28.1", + "@biomejs/biome": "^2.0.0", + "@commitlint/cli": "^21.0.0", + "@commitlint/config-conventional": "^21.0.0", + "@stryker-mutator/core": "^9.0.0", + "@types/node": "^25.0.0", + "esbuild": "^0.28.0", "fast-check": "^4.8.0", "husky": "^9.0.0", "tinybench": "^6.0.2", - "tstyche": "^7.2.1" + "tstyche": "^7.2.0" }, "overrides": { + "qs": "^6.15.2", "@sveltejs/kit": { "cookie": "^0.7.2" }, @@ -85,6 +93,19 @@ }, "license-check-and-add": { "minimatch": "^10.2.5" + }, + "lockfile-lint": { + "lockfile-lint-api": { + "@yarnpkg/parsers": { + "js-yaml": ">=4.2.0" + } + } + }, + "miniflare": { + "ws": "^8.21.0" + }, + "wrangler": { + "esbuild": ">=0.28.1" } }, "workspaces": [ diff --git a/packages/arrow/README.md b/packages/arrow/README.md new file mode 100644 index 0000000..799be45 --- /dev/null +++ b/packages/arrow/README.md @@ -0,0 +1,52 @@ +
+

<datastream> `arrow`

+ datastream logo +

Apache Arrow record batch transform streams.

+

+ GitHub Actions unit test status + GitHub Actions dast test status + GitHub Actions perf test status + GitHub Actions SAST test status + GitHub Actions lint test status +
+ npm version + npm install size + + npm weekly downloads + + npm provenance +
+ Open Source Security Foundation (OpenSSF) Scorecard + SLSA 3 + + Checked with Biome + Conventional Commits + + code coverage +

+

You can read the documentation at: https://datastream.js.org

+
+ + +## Install + +To install datastream you can use NPM: + +```bash +npm install --save @datastream/arrow +``` + + +## Documentation and examples + +For documentation and examples, refer to the main [datastream monorepo on GitHub](https://github.com/willfarrell/datastream) or [datastream official website](https://datastream.js.org). + + +## Contributing + +Everyone is very welcome to contribute to this repository. Feel free to [raise issues](https://github.com/willfarrell/datastream/issues) or to [submit Pull Requests](https://github.com/willfarrell/datastream/pulls). + + +## License + +Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell), and [datastream contributors](https://github.com/willfarrell/datastream/graphs/contributors). \ No newline at end of file diff --git a/packages/arrow/index.d.ts b/packages/arrow/index.d.ts new file mode 100644 index 0000000..84d7dd6 --- /dev/null +++ b/packages/arrow/index.d.ts @@ -0,0 +1,42 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import type { + DatastreamPassThrough, + DatastreamTransform, + ResultStream, + StreamOptions, +} from "@datastream/core"; +import type { RecordBatch, Schema } from "apache-arrow"; + +export interface ArrowDetectSchemaResult { + schema: Schema | null; + fields: string[] | null; +} + +export function arrowDetectSchemaStream( + options?: { sampleSize?: number; resultKey?: string }, + streamOptions?: StreamOptions, +): DatastreamPassThrough & ResultStream; + +export function arrowBatchFromArrayStream( + options: { schema: Schema | (() => Schema); batchSize?: number }, + streamOptions?: StreamOptions, +): DatastreamTransform; + +export function arrowBatchFromObjectStream( + options: { + schema: Schema | (() => Schema); + batchSize?: number; + }, + streamOptions?: StreamOptions, +): DatastreamTransform, RecordBatch>; + +export function arrowToArrayStream( + options?: Record, + streamOptions?: StreamOptions, +): DatastreamTransform; + +export function arrowToObjectStream( + options?: Record, + streamOptions?: StreamOptions, +): DatastreamTransform>; diff --git a/packages/arrow/index.fuzz.js b/packages/arrow/index.fuzz.js new file mode 100644 index 0000000..53c3d8d --- /dev/null +++ b/packages/arrow/index.fuzz.js @@ -0,0 +1,176 @@ +import test from "node:test"; +import { + arrowBatchFromArrayStream, + arrowBatchFromObjectStream, + arrowDetectSchemaStream, + arrowToArrayStream, + arrowToObjectStream, +} from "@datastream/arrow"; +import { + createReadableStream, + pipejoin, + streamToArray, +} from "@datastream/core"; +import fc from "fast-check"; + +const catchError = (input, e) => { + const expectedErrors = []; + if (expectedErrors.includes(e.message)) { + return; + } + console.error(input, e); + throw e; +}; + +// Helper: run arrowDetectSchemaStream in isolation and return the detected schema. +// Returns null when the input produces no detectable schema (e.g. empty stream or +// all-empty objects). +const detectSchema = async (input) => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + await streamToArray(pipejoin([createReadableStream(input), detect])); + return detect.result().value.schema; +}; + +// *** arrowDetectSchemaStream *** // +test("fuzz arrowDetectSchemaStream w/ object array input", async () => { + await fc.assert( + fc.asyncProperty(fc.array(fc.object({ maxDepth: 0 })), async (input) => { + try { + await detectSchema(input); + } catch (e) { + catchError(input, e); + } + }), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** arrowBatchFromObjectStream via detect *** // +// Skip when the detected schema is null (empty stream) or has no fields (all-empty +// objects were sampled), as those are valid edge-cases tested by the unit suite. +test("fuzz arrowBatchFromObjectStream w/ detected schema", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.object({ maxDepth: 0 }), { minLength: 1 }), + async (input) => { + try { + const schema = await detectSchema(input); + if (schema === null || schema.fields.length === 0) { + return; + } + const streams = [ + createReadableStream(input), + arrowBatchFromObjectStream({ schema, batchSize: 50 }), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError(input, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** arrowBatchFromArrayStream via detect *** // +test("fuzz arrowBatchFromArrayStream w/ detected schema", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.array(fc.oneof(fc.integer(), fc.string(), fc.boolean()), { + minLength: 1, + maxLength: 5, + }), + { minLength: 1 }, + ), + async (input) => { + try { + const schema = await detectSchema(input); + if (schema === null || schema.fields.length === 0) { + return; + } + const streams = [ + createReadableStream(input), + arrowBatchFromArrayStream({ schema, batchSize: 50 }), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError(input, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** detect -> batch -> arrowToObjectStream roundtrip *** // +test("fuzz arrowToObjectStream roundtrip w/ detected schema", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.object({ maxDepth: 0 }), { minLength: 1 }), + async (input) => { + try { + const schema = await detectSchema(input); + if (schema === null || schema.fields.length === 0) { + return; + } + const streams = [ + createReadableStream(input), + arrowBatchFromObjectStream({ schema, batchSize: 50 }), + arrowToObjectStream(), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError(input, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** detect -> batch -> arrowToArrayStream roundtrip *** // +test("fuzz arrowToArrayStream roundtrip w/ detected schema", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.object({ maxDepth: 0 }), { minLength: 1 }), + async (input) => { + try { + const schema = await detectSchema(input); + if (schema === null || schema.fields.length === 0) { + return; + } + const streams = [ + createReadableStream(input), + arrowBatchFromObjectStream({ schema, batchSize: 50 }), + arrowToArrayStream(), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError(input, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); diff --git a/packages/arrow/index.js b/packages/arrow/index.js new file mode 100644 index 0000000..a936363 --- /dev/null +++ b/packages/arrow/index.js @@ -0,0 +1,296 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { createTransformStream, resolveLazy } from "@datastream/core"; +import { + Bool, + Field, + Float64, + Int32, + makeBuilder, + makeData, + RecordBatch, + Schema, + Struct, + TimestampMillisecond, + Utf8, +} from "apache-arrow"; + +const inferType = (value) => { + if (value === null || value === undefined || value === "") return null; + if (typeof value === "boolean") return new Bool(); + if (typeof value === "number") { + if (!Number.isInteger(value)) return new Float64(); + // Integers outside the signed 32-bit range silently wrap inside an Int32 + // builder, so widen them to Float64 (exact up to 2^53). + return value > 2147483647 || value < -2147483648 + ? new Float64() + : new Int32(); + } + if (value instanceof Date) return new TimestampMillisecond(); + return new Utf8(); +}; + +const fieldsFromSamples = (samples, fieldNames, isArray) => { + return fieldNames.map((name, idx) => { + let type = null; + for (const sample of samples) { + const v = isArray ? sample[idx] : sample[name]; + type = inferType(v); + if (type !== null) break; + } + return new Field(name, type ?? new Utf8(), true); + }); +}; + +const isTimestampType = (type) => type instanceof TimestampMillisecond; + +export const arrowDetectSchemaStream = ( + { sampleSize = 100, resultKey } = {}, + streamOptions = {}, +) => { + const value = { schema: null, fields: null }; + const samples = []; + let sealed = false; + + const seal = () => { + // Nothing to seal until at least one row has been sampled. Once sealed, + // `samples` is cleared (below), so this same guard makes a repeat call a + // no-op without needing a separate sealed flag here. + if (!samples.length) return; + const isArray = Array.isArray(samples[0]); + let fieldNames; + if (isArray) { + // Column count is the widest array seen across all sampled rows. + let width = 0; + for (const sample of samples) { + width = Math.max(width, sample.length); + } + fieldNames = []; + for (let i = 0; i < width; i++) fieldNames.push(`column${i}`); + } else { + // Union the keys across every sampled row (preserving first-seen order), + // so columns that only appear in later rows are not dropped. + const seen = new Set(); + fieldNames = []; + for (const sample of samples) { + for (const key of Object.keys(sample)) { + if (!seen.has(key)) { + seen.add(key); + fieldNames.push(key); + } + } + } + } + const fields = fieldsFromSamples(samples, fieldNames, isArray); + value.schema = new Schema(fields); + value.fields = fieldNames; + sealed = true; + // The sampled rows are no longer needed once the schema is computed; + // release them so they are not retained for the stream's lifetime. + samples.length = 0; + }; + + const buffered = []; + const transform = (chunk, enqueue) => { + if (sealed) { + enqueue(chunk); + return; + } + samples.push(chunk); + buffered.push(chunk); + if (samples.length >= sampleSize) { + seal(); + while (buffered.length) enqueue(buffered.shift()); + } + }; + const flush = (enqueue) => { + // Seal a short stream that never reached sampleSize, then flush whatever is + // still buffered. If the stream already sealed mid-stream, `samples` is + // empty so seal() is a no-op and `buffered` has already been drained. + seal(); + while (buffered.length) enqueue(buffered.shift()); + }; + + const stream = createTransformStream(transform, flush, streamOptions); + stream.result = () => ({ key: resultKey ?? "arrowDetectSchema", value }); + return stream; +}; + +const buildRecordBatch = (schema, builders, length) => { + const childrenData = builders.map((b) => b.flush()); + const structData = makeData({ + type: new Struct(schema.fields), + length, + children: childrenData, + }); + return new RecordBatch(schema, structData); +}; + +const makeBuilders = (schema) => + schema.fields.map((field) => + makeBuilder({ type: field.type, nullValues: [null, undefined] }), + ); + +// A zero-field schema cannot represent rows: apache-arrow derives a +// RecordBatch's length from its child columns, so with no columns every batch +// reports numRows 0 and silently discards every row. Reject it explicitly +// instead of emitting corrupt empty batches. +const assertSchema = (name, schema) => { + if (!schema) throw new Error(`${name}: schema is required`); + if (schema.fields.length === 0) + throw new Error(`${name}: schema must have at least one field`); + return schema; +}; + +export const arrowBatchFromArrayStream = ( + { schema, batchSize = 10_000 } = {}, + streamOptions = {}, +) => { + let resolvedSchema; + let builders; + let rowCount = 0; + + const init = () => { + if (builders) return; + resolvedSchema = assertSchema( + "arrowBatchFromArrayStream", + resolveLazy(schema), + ); + builders = makeBuilders(resolvedSchema); + }; + + // Eagerly validate a concrete schema at construction so a missing/misconfigured + // schema surfaces at the call site rather than only once rows happen to flow. + if (typeof schema !== "function") + assertSchema("arrowBatchFromArrayStream", resolveLazy(schema)); + + const transform = (row, enqueue) => { + init(); + for (let i = 0, l = builders.length; i < l; i++) { + builders[i].append(row[i]); + } + rowCount++; + if (rowCount >= batchSize) { + enqueue(buildRecordBatch(resolvedSchema, builders, rowCount)); + rowCount = 0; + } + }; + const flush = (enqueue) => { + // Always run init so a missing lazy schema throws even for an empty stream. + init(); + if (rowCount > 0) { + enqueue(buildRecordBatch(resolvedSchema, builders, rowCount)); + rowCount = 0; + } + }; + + return createTransformStream(transform, flush, streamOptions); +}; + +export const arrowBatchFromObjectStream = ( + { schema, batchSize = 10_000 } = {}, + streamOptions = {}, +) => { + let resolvedSchema; + let fieldNames; + let builders; + let rowCount = 0; + + const init = () => { + if (builders) return; + resolvedSchema = assertSchema( + "arrowBatchFromObjectStream", + resolveLazy(schema), + ); + fieldNames = resolvedSchema.fields.map((f) => f.name); + builders = makeBuilders(resolvedSchema); + }; + + // Eagerly validate a concrete schema at construction so a missing/misconfigured + // schema surfaces at the call site rather than only once rows happen to flow. + if (typeof schema !== "function") + assertSchema("arrowBatchFromObjectStream", resolveLazy(schema)); + + const transform = (row, enqueue) => { + init(); + for (let i = 0, l = builders.length; i < l; i++) { + builders[i].append(row[fieldNames[i]]); + } + rowCount++; + if (rowCount >= batchSize) { + enqueue(buildRecordBatch(resolvedSchema, builders, rowCount)); + rowCount = 0; + } + }; + const flush = (enqueue) => { + // Always run init so a missing lazy schema throws even for an empty stream. + init(); + if (rowCount > 0) { + enqueue(buildRecordBatch(resolvedSchema, builders, rowCount)); + rowCount = 0; + } + }; + + return createTransformStream(transform, flush, streamOptions); +}; + +// Read one cell, restoring Date for timestamp columns (which Arrow vectors +// otherwise return as raw epoch-millisecond numbers) so round-trips preserve type. +const readCell = (col, isTimestamp, r) => { + const value = col.get(r); + if (isTimestamp && typeof value === "number") return new Date(value); + return value; +}; + +export const arrowToArrayStream = (_options = {}, streamOptions = {}) => { + const transform = (batch, enqueue) => { + const colCount = batch.schema.fields.length; + const cols = []; + const isTimestamp = []; + for (let i = 0; i < colCount; i++) { + cols.push(batch.getChildAt(i)); + isTimestamp.push(isTimestampType(batch.schema.fields[i].type)); + } + const rowCount = batch.numRows; + for (let r = 0; r < rowCount; r++) { + // The row is fully populated by the loop below, so build it by pushing + // each cell rather than pre-sizing (which is observationally identical). + const row = []; + for (let i = 0; i < colCount; i++) + row.push(readCell(cols[i], isTimestamp[i], r)); + enqueue(row); + } + }; + return createTransformStream(transform, streamOptions); +}; + +export const arrowToObjectStream = (_options = {}, streamOptions = {}) => { + const transform = (batch, enqueue) => { + const fields = batch.schema.fields; + const colCount = fields.length; + const names = []; + const cols = []; + const isTimestamp = []; + for (let i = 0; i < colCount; i++) { + names.push(fields[i].name); + cols.push(batch.getChildAt(i)); + isTimestamp.push(isTimestampType(fields[i].type)); + } + const rowCount = batch.numRows; + for (let r = 0; r < rowCount; r++) { + const row = {}; + for (let i = 0; i < colCount; i++) + row[names[i]] = readCell(cols[i], isTimestamp[i], r); + enqueue(row); + } + }; + return createTransformStream(transform, streamOptions); +}; + +export default { + detectSchemaStream: arrowDetectSchemaStream, + batchFromArrayStream: arrowBatchFromArrayStream, + batchFromObjectStream: arrowBatchFromObjectStream, + toArrayStream: arrowToArrayStream, + toObjectStream: arrowToObjectStream, +}; diff --git a/packages/arrow/index.perf.js b/packages/arrow/index.perf.js new file mode 100644 index 0000000..a0aff3f --- /dev/null +++ b/packages/arrow/index.perf.js @@ -0,0 +1,125 @@ +import test from "node:test"; +import { + arrowBatchFromObjectStream, + arrowDetectSchemaStream, + arrowToArrayStream, + arrowToObjectStream, +} from "@datastream/arrow"; +import { + createReadableStream, + pipejoin, + streamToArray, +} from "@datastream/core"; +import { Field, Int32, Schema, Utf8 } from "apache-arrow"; +import { Bench } from "tinybench"; + +// -- Data generators -- + +const ITEMS = 10_000; +const time = Number(process.env.BENCH_TIME ?? 5_000); + +const usersSchema = new Schema([ + new Field("id", new Int32(), true), + new Field("name", new Utf8(), true), +]); + +const objects = Array.from({ length: ITEMS }, (_, i) => ({ + id: i, + name: `user_${i}`, +})); + +// -- Tests -- + +test("perf: arrowDetectSchemaStream", async () => { + const bench = new Bench({ name: "arrowDetectSchemaStream", time }); + + bench.add(`${ITEMS} objects`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 100 }); + const streams = [createReadableStream(objects), detect]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: arrowBatchFromObjectStream", async () => { + const bench = new Bench({ name: "arrowBatchFromObjectStream", time }); + + bench.add(`${ITEMS} objects, batchSize 1000`, async () => { + const streams = [ + createReadableStream(objects), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 1_000 }), + ]; + await streamToArray(pipejoin(streams)); + }); + + bench.add(`${ITEMS} objects, batchSize 100`, async () => { + const streams = [ + createReadableStream(objects), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 100 }), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: arrowToObjectStream (batch -> objects)", async () => { + const bench = new Bench({ name: "arrowToObjectStream", time }); + + bench.add(`${ITEMS} objects, batchSize 1000`, async () => { + const streams = [ + createReadableStream(objects), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 1_000 }), + arrowToObjectStream(), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: arrowToArrayStream (batch -> arrays)", async () => { + const bench = new Bench({ name: "arrowToArrayStream", time }); + + bench.add(`${ITEMS} objects, batchSize 1000`, async () => { + const streams = [ + createReadableStream(objects), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 1_000 }), + arrowToArrayStream(), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: object roundtrip (detect -> batch -> objects)", async () => { + const bench = new Bench({ name: "object roundtrip", time }); + + bench.add(`${ITEMS} objects`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 100 }); + const streams = [ + createReadableStream(objects), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + batchSize: 1_000, + }), + arrowToObjectStream(), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); diff --git a/packages/arrow/index.test.js b/packages/arrow/index.test.js new file mode 100644 index 0000000..c04c09a --- /dev/null +++ b/packages/arrow/index.test.js @@ -0,0 +1,822 @@ +import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; +import test from "node:test"; +import { + arrowBatchFromArrayStream, + arrowBatchFromObjectStream, + arrowDetectSchemaStream, + arrowToArrayStream, + arrowToObjectStream, +} from "@datastream/arrow"; +import { + createReadableStream, + pipejoin, + streamToArray, +} from "@datastream/core"; +import { Field, Float64, Int32, RecordBatch, Schema, Utf8 } from "apache-arrow"; + +let variant = "unknown"; +for (const execArgv of process.execArgv) { + const flag = "--conditions="; + if (execArgv.includes(flag)) { + variant = execArgv.replace(flag, ""); + } +} + +const usersSchema = new Schema([ + new Field("id", new Int32(), true), + new Field("name", new Utf8(), true), +]); + +// *** arrowDetectSchemaStream *** // +test(`${variant}: arrowDetectSchemaStream should infer schema from object rows`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + const stream = pipejoin([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + { id: 3, name: "carol" }, + ]), + detect, + ]); + const output = await streamToArray(stream); + + strictEqual(output.length, 3); + const { value } = detect.result(); + ok(value.schema); + deepStrictEqual(value.fields, ["id", "name"]); + strictEqual(value.schema.fields.length, 2); + strictEqual(value.schema.fields[0].name, "id"); + strictEqual(value.schema.fields[1].name, "name"); +}); + +test(`${variant}: arrowDetectSchemaStream should infer column names for array rows`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([ + createReadableStream([ + [1, "alice"], + [2, "bob"], + ]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + deepStrictEqual(value.fields, ["column0", "column1"]); +}); + +test(`${variant}: arrowDetectSchemaStream widens out-of-range integers to Float64 (no Int32 overflow)`, async () => { + const big = 3_000_000_000; // > 2^31, overflows Int32 + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([ + createReadableStream([{ id: big, small: 5 }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + // Out-of-range integer must not be inferred as Int32 (which silently wraps). + strictEqual(value.schema.fields[0].type instanceof Float64, true); + // In-range integer stays Int32. + strictEqual(value.schema.fields[1].type instanceof Int32, true); +}); + +test(`${variant}: detect -> batch -> rows preserves a large integer exactly (no Int32 wraparound)`, async () => { + const input = [{ id: 3_000_000_000, name: "alice" }]; + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([ + createReadableStream(input), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + batchSize: 10, + }), + arrowToObjectStream(), + ]); + const rows = await streamToArray(stream); + strictEqual(rows[0].id, 3_000_000_000); + strictEqual(rows[0].name, "alice"); +}); + +test(`${variant}: arrowDetectSchemaStream should seal on flush when fewer rows than sampleSize`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 100 }); + const stream = pipejoin([ + createReadableStream([{ id: 1, name: "alice" }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + ok(value.schema); + deepStrictEqual(value.fields, ["id", "name"]); +}); + +// The schema must be sealed mid-stream exactly when `samples.length` REACHES +// sampleSize, using only the rows seen so far. With sampleSize 2 the first two +// 1-element array rows seal a single-column schema; the wider later row +// [3, 4, 5] arrives after sealing and must NOT widen the schema. A mutant that +// defers sealing (>= -> >, the guard removed, or `if (false)`) would instead +// sample the wider row and infer three columns. +test(`${variant}: arrowDetectSchemaStream seals mid-stream at sampleSize, ignoring wider later rows`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + const stream = pipejoin([ + createReadableStream([[1], [2], [3, 4, 5]]), + detect, + ]); + const rows = await streamToArray(stream); + const { value } = detect.result(); + deepStrictEqual(value.fields, ["column0"]); + strictEqual(value.schema.fields.length, 1); + // All input rows still flow through unchanged (the wider row is passed as-is). + strictEqual(rows.length, 3); + deepStrictEqual(rows[2], [3, 4, 5]); +}); + +// *** arrowBatchFromArrayStream *** // +test(`${variant}: arrowBatchFromArrayStream should emit a RecordBatch per batchSize rows`, async () => { + const stream = pipejoin([ + createReadableStream([ + [1, "a"], + [2, "b"], + [3, "c"], + ]), + arrowBatchFromArrayStream({ schema: usersSchema, batchSize: 2 }), + ]); + const batches = await streamToArray(stream); + + strictEqual(batches.length, 2); + ok(batches[0] instanceof RecordBatch); + strictEqual(batches[0].numRows, 2); + strictEqual(batches[1].numRows, 1); + strictEqual(batches[0].getChildAt(0).get(0), 1); + strictEqual(batches[0].getChildAt(1).get(1), "b"); +}); + +test(`${variant}: arrowBatchFromArrayStream should resolve a lazy schema`, async () => { + const stream = pipejoin([ + createReadableStream([[1, "a"]]), + arrowBatchFromArrayStream({ + schema: () => usersSchema, + batchSize: 10, + }), + ]); + const batches = await streamToArray(stream); + strictEqual(batches.length, 1); + strictEqual(batches[0].numRows, 1); +}); + +// *** arrowBatchFromObjectStream *** // +test(`${variant}: arrowBatchFromObjectStream should batch object rows`, async () => { + const stream = pipejoin([ + createReadableStream([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 10 }), + ]); + const batches = await streamToArray(stream); + strictEqual(batches.length, 1); + strictEqual(batches[0].numRows, 2); + strictEqual(batches[0].getChildAt(0).get(1), 2); + strictEqual(batches[0].getChildAt(1).get(0), "a"); +}); + +// *** arrowToArrayStream / arrowToObjectStream *** // +test(`${variant}: arrowToArrayStream should emit one array row per record-batch row`, async () => { + const buildStream = pipejoin([ + createReadableStream([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 10 }), + arrowToArrayStream(), + ]); + const rows = await streamToArray(buildStream); + deepStrictEqual(rows, [ + [1, "a"], + [2, "b"], + ]); +}); + +test(`${variant}: round-trip rows -> arrow -> rows preserves data`, async () => { + const input = [ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + { id: 3, name: "carol" }, + ]; + const stream = pipejoin([ + createReadableStream(input), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 2 }), + arrowToObjectStream(), + ]); + const rows = await streamToArray(stream); + deepStrictEqual(rows, input); +}); + +// *** lazy schema wired to detect *** // +test(`${variant}: detect + batchFromObject end-to-end with lazy schema`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + const stream = pipejoin([ + createReadableStream([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "c" }, + ]), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + batchSize: 10, + }), + arrowToObjectStream(), + ]); + const rows = await streamToArray(stream); + deepStrictEqual(rows, [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "c" }, + ]); +}); + +// *** heterogeneous keys: schema detection must union across all sampled rows *** // +test(`${variant}: arrowDetectSchemaStream unions object keys across all sampled rows`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ id: 1 }, { id: 2, name: "bob" }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + deepStrictEqual(value.fields, ["id", "name"]); + strictEqual(value.schema.fields.length, 2); + // 'name' should be a Utf8 column inferred from the second row. + strictEqual(value.schema.fields[1].name, "name"); + strictEqual(value.schema.fields[1].type instanceof Utf8, true); +}); + +test(`${variant}: arrowDetectSchemaStream uses max array width across sampled rows`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([[1], [2, "bob", true]]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + deepStrictEqual(value.fields, ["column0", "column1", "column2"]); + strictEqual(value.schema.fields.length, 3); +}); + +test(`${variant}: detect -> batch -> rows preserves a column present only in a later row`, async () => { + const input = [{ id: 1 }, { id: 2, name: "bob" }]; + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream(input), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + batchSize: 10, + }), + arrowToObjectStream(), + ]); + const rows = await streamToArray(stream); + strictEqual(rows.length, 2); + strictEqual(rows[1].name, "bob"); +}); + +// *** Date round-trip *** // +test(`${variant}: detect -> batch -> rows round-trips Date values as Date`, async () => { + const when = new Date("2020-06-01T00:00:00.000Z"); + const input = [{ id: 1, at: when }]; + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([ + createReadableStream(input), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + batchSize: 10, + }), + arrowToObjectStream(), + ]); + const rows = await streamToArray(stream); + ok(rows[0].at instanceof Date, "at should come back as a Date"); + strictEqual(rows[0].at.getTime(), when.getTime()); +}); + +test(`${variant}: arrowToArrayStream round-trips Date values as Date`, async () => { + const when = new Date("2020-06-01T00:00:00.000Z"); + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([ + createReadableStream([{ at: when }]), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + batchSize: 10, + }), + arrowToArrayStream(), + ]); + const rows = await streamToArray(stream); + ok(rows[0][0] instanceof Date, "at should come back as a Date"); + strictEqual(rows[0][0].getTime(), when.getTime()); +}); + +// *** empty / zero-field schema must error rather than silently lose rows *** // +// apache-arrow derives a RecordBatch's length from its child columns, so a +// zero-field schema can never carry rows; rejecting it prevents corrupt +// numRows:0 batches that silently discard data. +test(`${variant}: arrowBatchFromObjectStream rejects a zero-field schema instead of losing rows`, () => { + throws( + () => arrowBatchFromObjectStream({ schema: new Schema([]), batchSize: 10 }), + /at least one field/, + ); +}); + +test(`${variant}: arrowBatchFromArrayStream rejects a zero-field schema instead of losing rows`, () => { + throws( + () => arrowBatchFromArrayStream({ schema: new Schema([]), batchSize: 10 }), + /at least one field/, + ); +}); + +// *** missing-schema validation, including empty input *** // +// A concrete missing schema is rejected eagerly at construction. +test(`${variant}: arrowBatchFromObjectStream throws eagerly when schema is missing`, () => { + throws(() => arrowBatchFromObjectStream({}), /schema is required/); + throws(() => arrowBatchFromObjectStream(), /schema is required/); +}); + +test(`${variant}: arrowBatchFromArrayStream throws eagerly when schema is missing`, () => { + throws(() => arrowBatchFromArrayStream({}), /schema is required/); + throws(() => arrowBatchFromArrayStream(), /schema is required/); +}); + +// A lazy schema that resolves to nothing must still error even for an empty +// stream: init runs on flush, so the config bug is surfaced (as a stream error) +// rather than silently swallowed. +const errorFromEmptyLazy = (build) => + new Promise((resolve, reject) => { + const src = createReadableStream([]); + const stream = build(); + stream.on("error", resolve); + stream.on("end", () => reject(new Error("expected an error, got end"))); + stream.on("close", () => reject(new Error("expected an error, got close"))); + src.pipe(stream); + stream.resume(); + }); + +test(`${variant}: arrowBatchFromObjectStream errors for a lazy missing schema even on empty input`, async () => { + const error = await errorFromEmptyLazy(() => + arrowBatchFromObjectStream({ schema: () => undefined }), + ); + ok(/schema is required/.test(error.message)); +}); + +test(`${variant}: arrowBatchFromArrayStream errors for a lazy missing schema even on empty input`, async () => { + const error = await errorFromEmptyLazy(() => + arrowBatchFromArrayStream({ schema: () => undefined }), + ); + ok(/schema is required/.test(error.message)); +}); + +// *** inferType: null/undefined/empty-string branch *** // +// Kills: LogicalOperator (|| -> &&), ConditionalExpression short-circuits, +// StringLiteral ("" -> "Stryker was here!") +test(`${variant}: inferType treats null as no-type (first non-null row wins)`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ x: null }, { x: 42 }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Int32, true); +}); + +test(`${variant}: inferType treats undefined as no-type (first non-null row wins)`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ x: undefined }, { x: 7 }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Int32, true); +}); + +test(`${variant}: inferType treats empty string as no-type (first non-empty row wins)`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ x: "" }, { x: "hello" }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Utf8, true); +}); + +// *** inferType: boolean branch *** // +// Kills: ConditionalExpression (false), StringLiteral ("" for "boolean") +test(`${variant}: inferType returns Bool typeId for boolean true`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([createReadableStream([{ flag: true }]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type.typeId, 6); // Bool typeId +}); + +test(`${variant}: inferType returns Bool typeId for boolean false`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ flag: false }, { flag: true }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type.typeId, 6); // Bool typeId +}); + +// *** inferType: number/Float64/Int32 boundary checks *** // +// Kills: ConditionalExpression (!isInteger false), EqualityOperator (>= vs >), +// lower-bound ConditionalExpression and EqualityOperator. +test(`${variant}: inferType returns Float64 for non-integer number`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([createReadableStream([{ x: 3.14 }]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Float64, true); +}); + +test(`${variant}: inferType returns Int32 for integer at INT32_MAX (2147483647)`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([createReadableStream([{ x: 2147483647 }]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Int32, true); +}); + +test(`${variant}: inferType returns Float64 for integer one above INT32_MAX (2147483648)`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([createReadableStream([{ x: 2147483648 }]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Float64, true); +}); + +test(`${variant}: inferType returns Int32 for integer at INT32_MIN (-2147483648)`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([createReadableStream([{ x: -2147483648 }]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Int32, true); +}); + +test(`${variant}: inferType returns Float64 for integer one below INT32_MIN (-2147483649)`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([createReadableStream([{ x: -2147483649 }]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Float64, true); +}); + +// *** inferType: Date branch *** // +// Kills: ConditionalExpression (false for instanceof Date) +test(`${variant}: inferType returns TimestampMillisecond typeId for Date values`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([createReadableStream([{ ts: new Date() }]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type.typeId, 10); // Timestamp typeId +}); + +// *** fieldsFromSamples: type !== null break + nullable Field *** // +// Kills: ConditionalExpression (true/false on break), EqualityOperator (=== null), +// BooleanLiteral (false for nullable). +test(`${variant}: fieldsFromSamples skips null rows and uses first non-null type`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ x: null }, { x: null }, { x: 99 }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Int32, true); + strictEqual(value.schema.fields[0].nullable, true); +}); + +// *** fieldsFromSamples: type ?? new Utf8() fallback when ALL sampled values are null *** // +// Kills: branch where every sampled value for a field is null/undefined/empty-string, +// so type stays null and the ?? fallback produces a Utf8 Field. +test(`${variant}: fieldsFromSamples falls back to Utf8 when all sampled values are null`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ x: null }, { x: null }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Utf8, true); + strictEqual(value.schema.fields[0].nullable, true); +}); + +// *** arrowDetectSchemaStream: value object must have schema + fields keys *** // +// Kills: ObjectLiteral ({} for {schema:null, fields:null}) +test(`${variant}: arrowDetectSchemaStream result value has schema and fields properties`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([createReadableStream([{ id: 1 }]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + ok("schema" in value, "value must have schema key"); + ok("fields" in value, "value must have fields key"); +}); + +// *** seal() guarding: empty stream leaves schema null *** // +// Kills: ConditionalExpression (false for sealed || !samples.length), +// LogicalOperator (|| -> &&). +test(`${variant}: arrowDetectSchemaStream leaves schema null for empty stream`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([createReadableStream([]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema, null); +}); + +// *** seal(): sealed = true flag *** // +// Kills: BooleanLiteral (false for sealed = true) +test(`${variant}: arrowDetectSchemaStream sealed flag prevents re-sealing`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + const stream = pipejoin([ + createReadableStream([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + ok(value.schema !== null); + strictEqual(value.schema.fields.length, 1); +}); + +// *** transform: samples.length >= sampleSize threshold *** // +// Kills: ConditionalExpression (false), EqualityOperator (> vs >=). +test(`${variant}: arrowDetectSchemaStream seals at exactly sampleSize rows`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + const stream = pipejoin([ + createReadableStream([{ id: 1 }, { id: 2 }]), + detect, + ]); + const rows = await streamToArray(stream); + const { value } = detect.result(); + strictEqual(rows.length, 2); + ok(value.schema !== null); +}); + +// *** transform: BlockStatement kill (buffered rows emitted after seal) *** // +// Kills: BlockStatement (empty block when sampleSize reached). +test(`${variant}: arrowDetectSchemaStream emits all rows when sampleSize is hit mid-stream`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + const stream = pipejoin([ + createReadableStream([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]), + detect, + ]); + const rows = await streamToArray(stream); + strictEqual(rows.length, 4); +}); + +// *** flush(): !sealed branch *** // +// Kills: ConditionalExpression (true for !sealed), BlockStatement (empty). +test(`${variant}: arrowDetectSchemaStream flush path emits the buffered row`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 100 }); + const stream = pipejoin([createReadableStream([{ id: 42 }]), detect]); + const rows = await streamToArray(stream); + strictEqual(rows.length, 1); + strictEqual(rows[0].id, 42); +}); + +// *** stream.result: ?? vs && and default key string *** // +// Kills: LogicalOperator (?? -> &&), StringLiteral ("" for "arrowDetectSchema"). +test(`${variant}: arrowDetectSchemaStream result key defaults to "arrowDetectSchema"`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([createReadableStream([{ id: 1 }]), detect]); + await streamToArray(stream); + strictEqual(detect.result().key, "arrowDetectSchema"); +}); + +test(`${variant}: arrowDetectSchemaStream result key uses provided resultKey`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 1, resultKey: "myKey" }); + const stream = pipejoin([createReadableStream([{ id: 1 }]), detect]); + await streamToArray(stream); + strictEqual(detect.result().key, "myKey"); +}); + +// *** makeBuilders: nullValues must include null and undefined *** // +// Kills: ArrayDeclaration ([] for [null, undefined]). +test(`${variant}: null field values survive object stream round-trip as null`, async () => { + const stream = pipejoin([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: null }, + ]), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 10 }), + arrowToObjectStream(), + ]); + const rows = await streamToArray(stream); + strictEqual(rows[1].name, null); +}); + +test(`${variant}: undefined field values survive array stream round-trip as null`, async () => { + const stream = pipejoin([ + createReadableStream([ + [1, "alice"], + [2, undefined], + ]), + arrowBatchFromArrayStream({ schema: usersSchema, batchSize: 10 }), + arrowToArrayStream(), + ]); + const rows = await streamToArray(stream); + strictEqual(rows[1][1], null); +}); + +// *** assertSchema error messages include function name *** // +// Kills: StringLiteral ("" for "arrowBatchFromArrayStream" / "arrowBatchFromObjectStream"). +test(`${variant}: arrowBatchFromArrayStream error message includes function name`, () => { + throws( + () => arrowBatchFromArrayStream({}), + (err) => { + ok(/arrowBatchFromArrayStream/.test(err.message), `got: ${err.message}`); + return true; + }, + ); +}); + +test(`${variant}: arrowBatchFromObjectStream error message includes function name`, () => { + throws( + () => arrowBatchFromObjectStream({}), + (err) => { + ok(/arrowBatchFromObjectStream/.test(err.message), `got: ${err.message}`); + return true; + }, + ); +}); + +// *** arrowBatchFromArrayStream flush: rowCount > 0 guards *** // +// Kills: ConditionalExpression (true), EqualityOperator (>= 0 vs > 0). +test(`${variant}: arrowBatchFromArrayStream flush emits no extra batch when rowCount is 0`, async () => { + const stream = pipejoin([ + createReadableStream([ + [1, "a"], + [2, "b"], + ]), + arrowBatchFromArrayStream({ schema: usersSchema, batchSize: 2 }), + ]); + const batches = await streamToArray(stream); + strictEqual(batches.length, 1); + strictEqual(batches[0].numRows, 2); +}); + +// *** arrowBatchFromObjectStream transform: rowCount >= batchSize + BlockStatement *** // +// Kills: EqualityOperator (> vs >=), ConditionalExpression (false), BlockStatement. +test(`${variant}: arrowBatchFromObjectStream emits batch at exactly batchSize rows`, async () => { + const stream = pipejoin([ + createReadableStream([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "c" }, + ]), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 2 }), + ]); + const batches = await streamToArray(stream); + strictEqual(batches.length, 2); + strictEqual(batches[0].numRows, 2); + strictEqual(batches[1].numRows, 1); +}); + +// *** arrowBatchFromObjectStream flush: rowCount > 0 guards *** // +// Kills: ConditionalExpression (true), EqualityOperator (>= 0 vs > 0). +test(`${variant}: arrowBatchFromObjectStream flush emits no extra batch when rowCount is 0`, async () => { + const stream = pipejoin([ + createReadableStream([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 2 }), + ]); + const batches = await streamToArray(stream); + strictEqual(batches.length, 1); + strictEqual(batches[0].numRows, 2); +}); + +// *** readCell: isTimestamp && typeof value === "number" *** // +// Kills: ConditionalExpression (isTimestamp && true). +test(`${variant}: readCell does not wrap null timestamp cell in Date`, async () => { + const when = new Date("2021-01-01T00:00:00.000Z"); + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + const stream = pipejoin([ + createReadableStream([{ ts: when }, { ts: null }]), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + batchSize: 10, + }), + arrowToObjectStream(), + ]); + const rows = await streamToArray(stream); + ok(rows[0].ts instanceof Date); + strictEqual(rows[1].ts, null); +}); + +// *** arrowToArrayStream: new Array(colCount) pre-allocates with colCount *** // +// Kills: ArrayDeclaration (new Array() instead of new Array(colCount)). +test(`${variant}: arrowToArrayStream row length equals schema column count`, async () => { + const stream = pipejoin([ + createReadableStream([{ id: 1, name: "alice" }]), + arrowBatchFromObjectStream({ schema: usersSchema, batchSize: 10 }), + arrowToArrayStream(), + ]); + const rows = await streamToArray(stream); + strictEqual(rows[0].length, 2); +}); + +// *** default export must contain all five functions *** // +// Kills: ObjectLiteral ({} for the default export object). +test(`${variant}: default export exposes all five stream factory functions`, async () => { + const mod = await import("@datastream/arrow"); + const def = mod.default; + ok(typeof def.detectSchemaStream === "function"); + ok(typeof def.batchFromArrayStream === "function"); + ok(typeof def.batchFromObjectStream === "function"); + ok(typeof def.toArrayStream === "function"); + ok(typeof def.toObjectStream === "function"); +}); +// *** fieldsFromSamples: first non-null type must win (break kills last-wins mutant) *** // +// Kills: ConditionalExpression (if (false) break - never breaks, last type wins). +test(`${variant}: fieldsFromSamples uses FIRST non-null type not last`, async () => { + // Two values with different types: Int32 first, Utf8 last. + // Real (break on first non-null): Int32. Mutant (never break): Utf8. + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ x: 42 }, { x: "hello" }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + strictEqual(value.schema.fields[0].type instanceof Int32, true); +}); + +// *** fieldsFromSamples: max array width uses > not >= or always-true *** // +// Kills: ConditionalExpression (if (true)) - always updates width to last sample's length. +test(`${variant}: arrowDetectSchemaStream uses max width across descending-width array rows`, async () => { + // First row has 3 cols, second has 1. Real: width=3 (never shrinks). + // Mutant (if true): width=1 (last row wins), giving only column0. + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([createReadableStream([[1, 2, 3], [4]]), detect]); + await streamToArray(stream); + const { value } = detect.result(); + deepStrictEqual(value.fields, ["column0", "column1", "column2"]); + strictEqual(value.schema.fields.length, 3); +}); + +// *** init() in arrowBatchFromArrayStream: error message from lazy schema must include function name *** // +// Kills: StringLiteral ( for arrowBatchFromArrayStream in init()). +test(`${variant}: arrowBatchFromArrayStream lazy schema error includes function name`, async () => { + const err = await errorFromEmptyLazy(() => + arrowBatchFromArrayStream({ schema: () => undefined }), + ); + ok(/arrowBatchFromArrayStream/.test(err.message), `got: ${err.message}`); +}); + +// *** init() in arrowBatchFromObjectStream: error message from lazy schema must include function name *** // +// Kills: StringLiteral ( for arrowBatchFromObjectStream in init()). +test(`${variant}: arrowBatchFromObjectStream lazy schema error includes function name`, async () => { + const err = await errorFromEmptyLazy(() => + arrowBatchFromObjectStream({ schema: () => undefined }), + ); + ok(/arrowBatchFromObjectStream/.test(err.message), `got: ${err.message}`); +}); +// *** Kill empty-string mutants by testing against a non-string next value *** // +// Kills: ConditionalExpression (false for value === ""), +// StringLiteral ("Stryker was here!" for ""). +// If empty-string is NOT skipped, it types as Utf8 and breaks. Next value (42) is +// never seen. Result would be Utf8 instead of Int32. +test(`${variant}: inferType skips empty string before a number (type from number row wins)`, async () => { + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + const stream = pipejoin([ + createReadableStream([{ x: "" }, { x: 42 }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + // Real: "" skipped, 42 wins → Int32. + // Mutant: "" not skipped, Utf8 typed, breaks → Utf8 (wrong). + strictEqual(value.schema.fields[0].type instanceof Int32, true); +}); +// *** Kill sealed = false mutant: schema must not be re-detected from later rows *** // +// Kills: BooleanLiteral (false for sealed = true in seal()). +// With sealed=false: seal() re-runs for every subsequent sampleSize rows, overwriting +// the schema with types from the new batch. With sealed=true: schema is frozen after +// the first sampleSize rows. +test(`${variant}: arrowDetectSchemaStream schema is frozen after first sampleSize rows`, async () => { + // sampleSize=2: rows 1-2 produce Int32 schema. Rows 3-4 produce Float64 schema. + // Real (sealed=true): schema stays Int32. + // Mutant (sealed=false): seal() re-runs on rows 3-4, overwriting to Float64. + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + const stream = pipejoin([ + createReadableStream([{ x: 1 }, { x: 2 }, { x: 3.14 }, { x: 2.72 }]), + detect, + ]); + await streamToArray(stream); + const { value } = detect.result(); + // Schema must reflect the FIRST two rows (Int32), not the last two (Float64). + strictEqual(value.schema.fields[0].type instanceof Int32, true); +}); diff --git a/packages/arrow/index.tst.ts b/packages/arrow/index.tst.ts new file mode 100644 index 0000000..885b0ac --- /dev/null +++ b/packages/arrow/index.tst.ts @@ -0,0 +1,74 @@ +/// +/// +import type { ArrowDetectSchemaResult } from "@datastream/arrow"; +import { + arrowBatchFromArrayStream, + arrowBatchFromObjectStream, + arrowDetectSchemaStream, + arrowToArrayStream, + arrowToObjectStream, +} from "@datastream/arrow"; +import type { Schema } from "apache-arrow"; +import { describe, expect, test } from "tstyche"; + +const schema = {} as Schema; + +describe("ArrowDetectSchemaResult", () => { + test("has schema and fields", () => { + expect().type.toBeAssignableTo<{ + schema: Schema | null; + fields: string[] | null; + }>(); + }); +}); + +describe("arrowDetectSchemaStream", () => { + test("accepts no options", () => { + expect(arrowDetectSchemaStream()).type.not.toBeAssignableTo(); + }); + + test("accepts sampleSize", () => { + expect( + arrowDetectSchemaStream({ sampleSize: 100 }), + ).type.not.toBeAssignableTo(); + }); + + test("exposes result", () => { + const stream = arrowDetectSchemaStream(); + expect(stream.result).type.not.toBeAssignableTo(); + }); +}); + +describe("arrowBatchFromArrayStream", () => { + test("requires schema", () => { + expect( + arrowBatchFromArrayStream({ schema }), + ).type.not.toBeAssignableTo(); + }); + + test("accepts lazy schema and batchSize", () => { + expect( + arrowBatchFromArrayStream({ schema: () => schema, batchSize: 512 }), + ).type.not.toBeAssignableTo(); + }); +}); + +describe("arrowBatchFromObjectStream", () => { + test("requires schema", () => { + expect( + arrowBatchFromObjectStream({ schema }), + ).type.not.toBeAssignableTo(); + }); +}); + +describe("arrowToArrayStream", () => { + test("accepts no options", () => { + expect(arrowToArrayStream()).type.not.toBeAssignableTo(); + }); +}); + +describe("arrowToObjectStream", () => { + test("accepts no options", () => { + expect(arrowToObjectStream()).type.not.toBeAssignableTo(); + }); +}); diff --git a/packages/arrow/package.json b/packages/arrow/package.json new file mode 100644 index 0000000..753a7ef --- /dev/null +++ b/packages/arrow/package.json @@ -0,0 +1,70 @@ +{ + "name": "@datastream/arrow", + "version": "0.6.1", + "description": "Apache Arrow record batch transform streams", + "type": "module", + "engines": { + "node": ">=24" + }, + "engineStrict": true, + "publishConfig": { + "access": "public" + }, + "main": "./index.node.mjs", + "module": "./index.web.mjs", + "exports": { + ".": { + "node": { + "import": { + "types": "./index.d.ts", + "default": "./index.node.mjs" + } + }, + "import": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "./webstream": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "types": "index.d.ts", + "files": [ + "*.mjs", + "*.map", + "*.d.ts" + ], + "scripts": { + "test": "npm run test:unit", + "test:unit": "node --test", + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" + }, + "license": "MIT", + "keywords": [ + "arrow", + "apache-arrow", + "transform", + "Web Stream API", + "Node Stream API" + ], + "author": { + "name": "datastream contributors", + "url": "https://github.com/willfarrell/datastream/graphs/contributors" + }, + "repository": { + "type": "git", + "url": "github:willfarrell/datastream", + "directory": "packages/arrow" + }, + "bugs": { + "url": "https://github.com/willfarrell/datastream/issues" + }, + "homepage": "https://datastream.js.org", + "dependencies": { + "@datastream/core": "0.6.1", + "apache-arrow": "21.1.0" + } +} diff --git a/packages/aws/README.md b/packages/aws/README.md index dc56e5b..a5e374b 100644 --- a/packages/aws/README.md +++ b/packages/aws/README.md @@ -1,6 +1,6 @@

<datastream> `aws`

- datastream logo + datastream logo

AWS service streams for CloudWatch Logs, DynamoDB, DynamoDB Streams, Kinesis, Lambda, S3, SNS, and SQS.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/aws/client.js b/packages/aws/client.js index 17b665c..f5e2040 100644 --- a/packages/aws/client.js +++ b/packages/aws/client.js @@ -1,11 +1,30 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT + +// AWS regions that expose FIPS 140-2/140-3 validated endpoints. This includes +// the US/Canada commercial regions and BOTH GovCloud regions (which most need +// FIPS and were previously, incorrectly, excluded). +const fipsRegions = new Set([ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "ca-west-1", + "us-gov-east-1", + "us-gov-west-1", +]); + +export const awsRegionSupportsFips = (region) => fipsRegions.has(region); + export const awsClientDefaults = { - useFipsEndpoint: [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ca-central-1", - ].includes(process.env.AWS_REGION), + // Lazy getter so AWS_REGION is resolved when a client is constructed rather + // than frozen at module-import time (test harnesses / lazy config set it + // after import). + get useFipsEndpoint() { + // Guard the global `process` access so this shared module can also be + // imported in non-node runtimes (browsers/edge) where `process` is + // undefined; there it simply resolves to the non-FIPS default. + return awsRegionSupportsFips(globalThis.process?.env?.AWS_REGION); + }, }; diff --git a/packages/aws/client.test.js b/packages/aws/client.test.js new file mode 100644 index 0000000..13fee9e --- /dev/null +++ b/packages/aws/client.test.js @@ -0,0 +1,110 @@ +import { deepStrictEqual } from "node:assert"; +import test from "node:test"; + +let variant = "unknown"; +for (const execArgv of process.execArgv) { + const flag = "--conditions="; + if (execArgv.includes(flag)) { + variant = execArgv.replace(flag, ""); + } +} + +// client.js has no build step (shipped as source); import it directly. +const { awsClientDefaults, awsRegionSupportsFips } = await import( + `file://${new URL("./client.js", import.meta.url).pathname}` +); + +test(`${variant}: awsClientDefaults.useFipsEndpoint resolves AWS_REGION lazily`, (_t) => { + const original = process.env.AWS_REGION; + try { + process.env.AWS_REGION = "eu-west-1"; + deepStrictEqual(awsClientDefaults.useFipsEndpoint, false); + + // Changing AWS_REGION after import must be reflected (not frozen at import) + process.env.AWS_REGION = "us-east-1"; + deepStrictEqual(awsClientDefaults.useFipsEndpoint, true); + } finally { + if (original === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = original; + } + } +}); + +test(`${variant}: awsRegionSupportsFips includes GovCloud regions`, (_t) => { + deepStrictEqual(awsRegionSupportsFips("us-gov-east-1"), true); + deepStrictEqual(awsRegionSupportsFips("us-gov-west-1"), true); + deepStrictEqual(awsRegionSupportsFips("ca-central-1"), true); + deepStrictEqual(awsRegionSupportsFips("eu-west-1"), false); + deepStrictEqual(awsRegionSupportsFips(undefined), false); +}); + +// In a browser/edge runtime `process` is undefined; reading process.env +// directly throws ReferenceError at module load / first client construction. +// The getter must tolerate a missing `process` global (web parity). +test(`${variant}: awsClientDefaults.useFipsEndpoint does not throw when process is undefined (browser)`, (_t) => { + const original = globalThis.process; + try { + // Simulate a browser global scope where `process` does not exist. + globalThis.process = undefined; + deepStrictEqual(awsClientDefaults.useFipsEndpoint, false); + } finally { + globalThis.process = original; + } +}); + +// client.js is shipped as raw source (no build step; not an export), so there is +// no client.node.mjs to import. Exercise the same source module to assert the +// full FIPS region set, the lookup function and the lazy getter. +const built = await import( + `file://${new URL("./client.js", import.meta.url).pathname}` +); + +test(`${variant}: built awsRegionSupportsFips matches the exact FIPS region set`, (_t) => { + // Every region that must be FIPS-capable (kills StringLiteral mutants that + // blank an individual region, and the Set/array-declaration mutants). + for (const region of [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "ca-west-1", + "us-gov-east-1", + "us-gov-west-1", + ]) { + deepStrictEqual(built.awsRegionSupportsFips(region), true); + } + // Regions NOT in the set must be false (kills the `() => undefined` mutant, + // which would make every lookup falsy/undefined rather than a real boolean). + deepStrictEqual(built.awsRegionSupportsFips("eu-west-1"), false); + deepStrictEqual(built.awsRegionSupportsFips("ap-southeast-2"), false); + deepStrictEqual(built.awsRegionSupportsFips(undefined), false); +}); + +test(`${variant}: built awsClientDefaults.useFipsEndpoint reflects AWS_REGION lazily`, (_t) => { + const original = process.env.AWS_REGION; + try { + process.env.AWS_REGION = "us-west-1"; + deepStrictEqual(built.awsClientDefaults.useFipsEndpoint, true); + process.env.AWS_REGION = "eu-central-1"; + deepStrictEqual(built.awsClientDefaults.useFipsEndpoint, false); + } finally { + if (original === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = original; + } + } +}); + +test(`${variant}: built awsClientDefaults.useFipsEndpoint tolerates a missing process global`, (_t) => { + const original = globalThis.process; + try { + globalThis.process = undefined; + deepStrictEqual(built.awsClientDefaults.useFipsEndpoint, false); + } finally { + globalThis.process = original; + } +}); diff --git a/packages/aws/cloudwatch-logs.js b/packages/aws/cloudwatch-logs.js index 90c1bb0..46290b3 100644 --- a/packages/aws/cloudwatch-logs.js +++ b/packages/aws/cloudwatch-logs.js @@ -5,6 +5,7 @@ import { FilterLogEventsCommand, GetLogEventsCommand, } from "@aws-sdk/client-cloudwatch-logs"; +import { timeout } from "@datastream/core"; import { awsClientDefaults } from "./client.js"; let client = new CloudWatchLogsClient(awsClientDefaults); @@ -19,7 +20,6 @@ export const awsCloudWatchLogsGetLogEventsStream = async ( const { pollingActive, pollingDelay = 1000, ...cwlOptions } = options; cwlOptions.startFromHead ??= true; async function* command(opts) { - let previousToken; let expectMore = true; while (expectMore) { const response = await client.send(new GetLogEventsCommand(opts), { @@ -29,16 +29,18 @@ export const awsCloudWatchLogsGetLogEventsStream = async ( for (const item of events) { yield item; } - const tokenUnchanged = - response.nextForwardToken === previousToken || - response.nextForwardToken === opts.nextToken; - previousToken = response.nextForwardToken; + // CloudWatch echoes the token you sent (opts.nextToken) back as + // nextForwardToken once there are no further events, so an unchanged + // token is the end-of-page / caught-up signal. + const tokenUnchanged = response.nextForwardToken === opts.nextToken; opts.nextToken = response.nextForwardToken; if (tokenUnchanged) { if (pollingActive) { if (pollingDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, pollingDelay)); + // Abortable idle wait: rejects immediately and clears the + // timer when streamOptions.signal aborts mid-delay. + await timeout(pollingDelay, { signal: streamOptions.signal }); } } else { expectMore = false; diff --git a/packages/aws/cloudwatch-logs.test.js b/packages/aws/cloudwatch-logs.test.js index 365c8a7..dd4ec33 100644 --- a/packages/aws/cloudwatch-logs.test.js +++ b/packages/aws/cloudwatch-logs.test.js @@ -1,11 +1,11 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import test from "node:test"; import { CloudWatchLogsClient, FilterLogEventsCommand, GetLogEventsCommand, } from "@aws-sdk/client-cloudwatch-logs"; -import { +import cwlDefault, { awsCloudWatchLogsFilterLogEventsStream, awsCloudWatchLogsGetLogEventsStream, awsCloudWatchLogsSetClient, @@ -124,6 +124,7 @@ test(`${variant}: awsCloudWatchLogsGetLogEventsStream should delay polling when client .on(GetLogEventsCommand) .resolvesOnce({ events: [], nextForwardToken: "token1" }) + .resolvesOnce({ events: [], nextForwardToken: "token1" }) .resolvesOnce({ events: [{ message: "log1" }], nextForwardToken: "token2" }) .resolves({ events: [], nextForwardToken: "token2" }); @@ -151,6 +152,41 @@ test(`${variant}: awsCloudWatchLogsGetLogEventsStream should delay polling when deepStrictEqual(output, [{ message: "log1" }]); }); +test(`${variant}: awsCloudWatchLogsGetLogEventsStream should abort the idle poll delay on signal`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + + // Same token every call => tokenUnchanged => enters the idle poll delay. + client.on(GetLogEventsCommand).resolves({ + events: [], + nextForwardToken: "same-token", + }); + + const controller = new AbortController(); + const options = { + logGroupName: "/test/group", + logStreamName: "stream1", + pollingActive: true, + pollingDelay: 60_000, + }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options, { + signal: controller.signal, + }); + + const consuming = (async () => { + for await (const _item of stream) { + // no events ever arrive + } + })(); + + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + // --- FilterLogEventsStream --- test(`${variant}: awsCloudWatchLogsFilterLogEventsStream should get events`, async (_t) => { @@ -221,3 +257,208 @@ test(`${variant}: awsCloudWatchLogsGetLogEventsStream should pass signal to clie const calls = client.commandCalls(GetLogEventsCommand); deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); }); + +// *** startFromHead default & options passthrough *** // +test(`${variant}: awsCloudWatchLogsGetLogEventsStream defaults startFromHead to true`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client + .on(GetLogEventsCommand) + .resolves({ events: [], nextForwardToken: "t" }); + + const options = { logGroupName: "g", logStreamName: "s" }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options); + await streamToArray(stream); + + const input = client.commandCalls(GetLogEventsCommand)[0].args[0].input; + // Defaulted via `??=`: a `&&=` mutant would leave it undefined (because + // undefined &&= true stays undefined). + deepStrictEqual(input.startFromHead, true); + // Caller options are passed through to the command. + deepStrictEqual(input.logGroupName, "g"); + deepStrictEqual(input.logStreamName, "s"); +}); + +test(`${variant}: awsCloudWatchLogsGetLogEventsStream keeps an explicit startFromHead=false`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client + .on(GetLogEventsCommand) + .resolves({ events: [], nextForwardToken: "t" }); + + const options = { + logGroupName: "g", + logStreamName: "s", + startFromHead: false, + }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options); + await streamToArray(stream); + + // `??=` must NOT overwrite an explicit false. A plain `=` mutant would force + // it back to true. + const input = client.commandCalls(GetLogEventsCommand)[0].args[0].input; + deepStrictEqual(input.startFromHead, false); +}); + +test(`${variant}: awsCloudWatchLogsFilterLogEventsStream passes options through and paginates on nextToken`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client + .on(FilterLogEventsCommand) + .resolvesOnce({ events: [{ message: "a" }], nextToken: "n2" }) + .resolvesOnce({ events: [{ message: "b" }] }); + + const options = { logGroupName: "g", filterPattern: "ERROR" }; + const stream = await awsCloudWatchLogsFilterLogEventsStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ message: "a" }, { message: "b" }]); + const calls = client.commandCalls(FilterLogEventsCommand); + // Two calls: pagination continued because the first response had a nextToken + // (kills the `expectMore = !!nextToken` related behavior) and stopped when the + // second had none. + deepStrictEqual(calls.length, 2); + // Options spread through to the command (kills the `command({})` mutant). + deepStrictEqual(calls[0].args[0].input.logGroupName, "g"); + deepStrictEqual(calls[0].args[0].input.filterPattern, "ERROR"); +}); + +test(`${variant}: awsCloudWatchLogsFilterLogEventsStream stops after a single page when no nextToken`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + // No nextToken on the only response => exactly one call. + client.on(FilterLogEventsCommand).resolves({ events: [{ message: "a" }] }); + + const stream = await awsCloudWatchLogsFilterLogEventsStream({ + logGroupName: "g", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ message: "a" }]); + deepStrictEqual(client.commandCalls(FilterLogEventsCommand).length, 1); +}); + +test(`${variant}: awsCloudWatchLogsFilterLogEventsStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client.on(FilterLogEventsCommand).resolves({ events: [{ message: "a" }] }); + + const controller = new AbortController(); + const stream = await awsCloudWatchLogsFilterLogEventsStream( + { logGroupName: "g" }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(FilterLogEventsCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +// *** setClient swap *** // +test(`${variant}: awsCloudWatchLogsSetClient routes to the new client`, async (_t) => { + const first = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(first); + first.on(FilterLogEventsCommand).resolves({ events: [] }); + + const second = mockClient(CloudWatchLogsClient); + second.on(FilterLogEventsCommand).resolves({ events: [] }); + awsCloudWatchLogsSetClient(second); + + const stream = await awsCloudWatchLogsFilterLogEventsStream({ + logGroupName: "g", + }); + await streamToArray(stream); + + deepStrictEqual(second.commandCalls(FilterLogEventsCommand).length, 1); + deepStrictEqual(first.commandCalls(FilterLogEventsCommand).length, 0); +}); + +// setClient must STORE the passed client (mockClient hides instance identity via +// prototype patching). A plain stub proves the stored reference is used; a +// `setClient(){}` mutant leaves the prior client in place. +test(`${variant}: awsCloudWatchLogsSetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { events: [{ message: "stub" }] }; + }, + }; + awsCloudWatchLogsSetClient(stub); + + const stream = await awsCloudWatchLogsFilterLogEventsStream({ + logGroupName: "g", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ message: "stub" }]); + deepStrictEqual(calls, 1); +}); + +// GetLogEvents stops as soon as nextForwardToken equals the token that was sent +// (opts.nextToken), even on the very first call when the caller resumes from a +// known token. Pins `nextForwardToken === opts.nextToken` (a `false`/always-loop +// mutant would never stop; this also kills the unchanged-token guard). +test(`${variant}: awsCloudWatchLogsGetLogEventsStream stops when the echoed token equals the sent token`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + // Caller resumes from "tokenX"; CloudWatch immediately echoes it back -> done. + client.on(GetLogEventsCommand).resolves({ + events: [{ message: "log1" }], + nextForwardToken: "tokenX", + }); + + const options = { + logGroupName: "g", + logStreamName: "s", + nextToken: "tokenX", + }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ message: "log1" }]); + // Exactly one call: the echoed token matched the sent token on the first page. + deepStrictEqual(client.commandCalls(GetLogEventsCommand).length, 1); +}); + +// pollingActive with pollingDelay === 0 must NOT schedule a timer: the +// `pollingDelay > 0` guard is false. A `true` or `>= 0` mutant would await +// timeout(0); under un-ticked mock timers the consumer would never reach the +// later (changed-token) event. +test(`${variant}: awsCloudWatchLogsGetLogEventsStream pollingDelay 0 schedules no timer`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client + .on(GetLogEventsCommand) + // caught-up page: token unchanged from the (undefined) sent token? No - + // send a repeated token so tokenUnchanged is true and we enter the polling + // branch, then a fresh token + event without any timer tick. + .resolvesOnce({ events: [], nextForwardToken: "t1" }) + .resolvesOnce({ events: [], nextForwardToken: "t1" }) + .resolvesOnce({ events: [{ message: "log1" }], nextForwardToken: "t2" }) + .resolves({ events: [], nextForwardToken: "t2" }); + + const options = { + logGroupName: "g", + logStreamName: "s", + pollingActive: true, + pollingDelay: 0, + }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 1) break; + } + deepStrictEqual(output, [{ message: "log1" }]); +}); + +test(`${variant}: cloudwatch-logs default export exposes all stream functions`, (_t) => { + deepStrictEqual(Object.keys(cwlDefault).sort(), [ + "filterLogEventsStream", + "getLogEventsStream", + "setClient", + ]); +}); diff --git a/packages/aws/dynamodb-streams.js b/packages/aws/dynamodb-streams.js index a1c219f..cf06704 100644 --- a/packages/aws/dynamodb-streams.js +++ b/packages/aws/dynamodb-streams.js @@ -4,6 +4,7 @@ import { DynamoDBStreamsClient, GetRecordsCommand, } from "@aws-sdk/client-dynamodb-streams"; +import { timeout } from "@datastream/core"; import { awsClientDefaults } from "./client.js"; let client = new DynamoDBStreamsClient(awsClientDefaults); @@ -30,7 +31,9 @@ export const awsDynamoDBStreamsGetRecordsStream = async ( expectMore = opts.ShardIterator !== null && (pollingActive || records.length > 0); if (pollingActive && records.length === 0 && pollingDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, pollingDelay)); + // Abortable idle wait: rejects immediately and clears the timer + // when streamOptions.signal aborts mid-delay. + await timeout(pollingDelay, { signal: streamOptions.signal }); } } } diff --git a/packages/aws/dynamodb-streams.test.js b/packages/aws/dynamodb-streams.test.js index 4af7917..3a05547 100644 --- a/packages/aws/dynamodb-streams.test.js +++ b/packages/aws/dynamodb-streams.test.js @@ -1,10 +1,10 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import test from "node:test"; import { DynamoDBStreamsClient, GetRecordsCommand, } from "@aws-sdk/client-dynamodb-streams"; -import { +import ddbStreamsDefault, { awsDynamoDBStreamsGetRecordsStream, awsDynamoDBStreamsSetClient, } from "@datastream/aws/dynamodb-streams"; @@ -159,6 +159,38 @@ test(`${variant}: awsDynamoDBStreamsGetRecordsStream should delay polling when p deepStrictEqual(output, [{ eventID: "1" }]); }); +test(`${variant}: awsDynamoDBStreamsGetRecordsStream should abort the idle poll delay on signal`, async (_t) => { + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + + client + .on(GetRecordsCommand) + .resolves({ Records: [], NextShardIterator: "i" }); + + const controller = new AbortController(); + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 60_000, + }; + const stream = await awsDynamoDBStreamsGetRecordsStream(options, { + signal: controller.signal, + }); + + const consuming = (async () => { + for await (const _item of stream) { + // no records ever arrive + } + })(); + + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + // *** AbortSignal *** // test(`${variant}: awsDynamoDBStreamsGetRecordsStream should pass signal to client`, async (_t) => { const client = mockClient(DynamoDBStreamsClient); @@ -178,3 +210,152 @@ test(`${variant}: awsDynamoDBStreamsGetRecordsStream should pass signal to clien const calls = client.commandCalls(GetRecordsCommand); deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); }); + +// *** options spread through to the command *** // +test(`${variant}: awsDynamoDBStreamsGetRecordsStream passes Limit through to GetRecords`, async (_t) => { + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [{ eventID: "1" }], NextShardIterator: null }); + + const stream = await awsDynamoDBStreamsGetRecordsStream({ + ShardIterator: "shard-abc", + Limit: 25, + }); + await streamToArray(stream); + + // `opts.ShardIterator` is reassigned inside the loop and captured by + // reference, so assert on `Limit` (not mutated) -- it proves the caller + // options were spread through (a `command({})` mutant would drop it). + const input = client.commandCalls(GetRecordsCommand)[0].args[0].input; + deepStrictEqual(input.Limit, 25); +}); + +// *** setClient swap *** // +test(`${variant}: awsDynamoDBStreamsSetClient routes to the new client`, async (_t) => { + const first = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(first); + first + .on(GetRecordsCommand) + .resolves({ Records: [], NextShardIterator: null }); + + const second = mockClient(DynamoDBStreamsClient); + second + .on(GetRecordsCommand) + .resolves({ Records: [], NextShardIterator: null }); + awsDynamoDBStreamsSetClient(second); + + const stream = await awsDynamoDBStreamsGetRecordsStream({ + ShardIterator: "i", + }); + await streamToArray(stream); + + deepStrictEqual(second.commandCalls(GetRecordsCommand).length, 1); + deepStrictEqual(first.commandCalls(GetRecordsCommand).length, 0); +}); + +// setClient must STORE the passed client; a plain stub (prototype-mock-proof) +// proves the stored reference is used. A `setClient(){}` mutant leaves the prior +// client in place. +test(`${variant}: awsDynamoDBStreamsSetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { Records: [{ eventID: "stub" }], NextShardIterator: null }; + }, + }; + awsDynamoDBStreamsSetClient(stub); + + const stream = await awsDynamoDBStreamsGetRecordsStream({ + ShardIterator: "iter1", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ eventID: "stub" }]); + deepStrictEqual(calls, 1); +}); + +// *** idle-wait guard conjuncts *** // +test(`${variant}: awsDynamoDBStreamsGetRecordsStream does not delay when records are present`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + // Records always present so the `records.length === 0` conjunct is false and + // no timer is scheduled. A `true` mutant on that conjunct would delay after + // every (non-empty) poll and the consumer would stall on the mock timer. + client.on(GetRecordsCommand).resolves({ + Records: [{ eventID: "1" }], + NextShardIterator: "iter-next", + }); + + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 60_000, + }; + const stream = await awsDynamoDBStreamsGetRecordsStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 3) break; + } + deepStrictEqual(output.length, 3); +}); + +test(`${variant}: awsDynamoDBStreamsGetRecordsStream pollingDelay 0 schedules no timer`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + // pollingActive, empty records, pollingDelay === 0: `pollingDelay > 0` is false + // so no idle wait. A `> 0` -> `true` or `>= 0` mutant would await timeout(0) + // which the un-ticked mock timer never resolves. + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [], NextShardIterator: "iter2" }) + .resolves({ Records: [{ eventID: "1" }], NextShardIterator: "iter3" }); + + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 0, + }; + const stream = await awsDynamoDBStreamsGetRecordsStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 1) break; + } + deepStrictEqual(output, [{ eventID: "1" }]); +}); + +test(`${variant}: awsDynamoDBStreamsGetRecordsStream non-polling empty page ends without an idle wait`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + // Not polling: an empty page ends the stream WITHOUT entering the idle wait. + // The `&&` -> `||` mutant makes `pollingActive || records.length === 0` true on + // the empty page, scheduling a (default 1000ms) timeout the un-ticked mock + // timer never resolves -> the stream would hang instead of completing. + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [{ eventID: "1" }], NextShardIterator: "iter2" }) + .resolves({ Records: [], NextShardIterator: "iter3" }); + + const stream = await awsDynamoDBStreamsGetRecordsStream({ + ShardIterator: "iter1", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ eventID: "1" }]); +}); + +test(`${variant}: dynamodb-streams default export exposes all stream functions`, (_t) => { + deepStrictEqual(Object.keys(ddbStreamsDefault).sort(), [ + "getRecordsStream", + "setClient", + ]); +}); diff --git a/packages/aws/dynamodb.js b/packages/aws/dynamodb.js index 5459659..50cf46a 100644 --- a/packages/aws/dynamodb.js +++ b/packages/aws/dynamodb.js @@ -17,6 +17,22 @@ export const awsDynamoDBSetClient = (ddbClient, _translateConfig) => { }; awsDynamoDBSetClient(client); +// UnprocessedItems/UnprocessedKeys are throttling-driven; a near-zero early +// delay (3^0 == 1ms) just hammers the table. Apply a floor so the first retries +// give capacity time to recover, while preserving the ~59sec cap (3^10). +const DYNAMODB_BACKOFF_FLOOR_MS = 50; +const DYNAMODB_BACKOFF_CAP_MS = 3 ** 10; +// streamOptions is always supplied by the callers (getItem / batchWrite, which +// receive the stream's streamOptions defaulting to {}), so it is never nullish. +const dynamodbBackoff = (retryCount, streamOptions) => + timeout( + Math.min( + DYNAMODB_BACKOFF_CAP_MS, + Math.max(DYNAMODB_BACKOFF_FLOOR_MS, 3 ** retryCount), + ), + { signal: streamOptions.signal }, + ); + // options = {TableName, ...} export const awsDynamoDBQueryStream = async (options, streamOptions = {}) => { @@ -26,7 +42,7 @@ export const awsDynamoDBQueryStream = async (options, streamOptions = {}) => { const response = await client.send(new QueryCommand(opts), { abortSignal: streamOptions.signal, }); - for (const item of response.Items) { + for (const item of response.Items ?? []) { yield item; } opts.ExclusiveStartKey = response.LastEvaluatedKey; @@ -43,7 +59,7 @@ export const awsDynamoDBScanStream = async (options, streamOptions = {}) => { const response = await client.send(new ScanCommand(opts), { abortSignal: streamOptions.signal, }); - for (const item of response.Items) { + for (const item of response.Items ?? []) { yield item; } opts.ExclusiveStartKey = response.LastEvaluatedKey; @@ -92,11 +108,11 @@ export const awsDynamoDBGetItemStream = async (options, streamOptions = {}) => { }), { abortSignal: streamOptions.signal }, ); - for (const item of response.Responses[options.TableName]) { + for (const item of response.Responses?.[options.TableName] ?? []) { yield item; } const UnprocessedKeys = - response?.UnprocessedKeys?.[options.TableName]?.Keys ?? []; + response.UnprocessedKeys?.[options.TableName]?.Keys ?? []; if (!UnprocessedKeys.length) { break; } @@ -110,7 +126,7 @@ export const awsDynamoDBGetItemStream = async (options, streamOptions = {}) => { }); } - await timeout(3 ** retryCount, { signal: streamOptions.signal }); // 3^10 == 59sec + await dynamodbBackoff(retryCount, streamOptions); retryCount++; keys = UnprocessedKeys; } @@ -171,7 +187,8 @@ const dynamodbBatchWrite = async ( [options.TableName]: batch, }, }), - { abortSignal: streamOptions?.signal }, + // streamOptions is always supplied by put/delete (defaulting to {}). + { abortSignal: streamOptions.signal }, ); if (UnprocessedItems?.[options.TableName]?.length) { if (retryCount >= retryMaxCount) { @@ -183,7 +200,7 @@ const dynamodbBatchWrite = async ( }); } - await timeout(3 ** retryCount, { signal: streamOptions?.signal }); // 3^10 == 59sec + await dynamodbBackoff(retryCount, streamOptions); return dynamodbBatchWrite( options, UnprocessedItems[options.TableName], diff --git a/packages/aws/dynamodb.test.js b/packages/aws/dynamodb.test.js index cc78a98..a97d56e 100644 --- a/packages/aws/dynamodb.test.js +++ b/packages/aws/dynamodb.test.js @@ -613,6 +613,448 @@ test(`${variant}: awsDynamoDBQueryStream should not mutate caller options`, asyn deepStrictEqual(options, optionsCopy); }); +test(`${variant}: awsDynamoDBGetItemStream should reject when Keys.length exceeds 100`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + const options = { + TableName: "TableName", + Keys: new Array(101).fill({ key: "x" }), + }; + await rejects( + () => awsDynamoDBGetItemStream(options), + /exceeds BatchGetItem limit of 100/, + ); +}); + +// *** empty-page / fully-throttled guards *** // +test(`${variant}: awsDynamoDBQueryStream should handle a page with no Items`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(QueryCommand, { TableName: "TableName" }) + .resolvesOnce({ + // filtered page: no Items but still paginating + Count: 0, + LastEvaluatedKey: { key: "a" }, + }) + .resolvesOnce({ + Items: [{ key: "b", value: 2 }], + }); + + const options = { TableName: "TableName" }; + const stream = await awsDynamoDBQueryStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "b", value: 2 }]); +}); + +test(`${variant}: awsDynamoDBScanStream should handle a page with no Items`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(ScanCommand, { TableName: "TableName" }) + .resolvesOnce({ + Count: 0, + LastEvaluatedKey: { key: "a" }, + }) + .resolvesOnce({ + Items: [{ key: "b", value: 2 }], + }); + + const options = { TableName: "TableName" }; + const stream = await awsDynamoDBScanStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "b", value: 2 }]); +}); + +test(`${variant}: awsDynamoDBGetItemStream should handle fully-throttled batch (empty Responses)`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand, { + RequestItems: { TableName: { Keys: [{ key: "a" }] } }, + }) + .resolvesOnce({ + // entire batch throttled: Responses has no entry for the table + Responses: {}, + UnprocessedKeys: { TableName: { Keys: [{ key: "a" }] } }, + }) + .resolvesOnce({ + Responses: { TableName: [{ key: "a", value: 1 }] }, + UnprocessedKeys: {}, + }); + + const options = { TableName: "TableName", Keys: [{ key: "a" }] }; + const stream = await awsDynamoDBGetItemStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "a", value: 1 }]); +}); + +// *** backoff is abortable: aborting during the backoff wait rejects *** // +test(`${variant}: awsDynamoDBGetItemStream aborts during retry backoff`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Always throttled so the stream enters the (real-timer) backoff wait. + client.on(BatchGetItemCommand).resolves({ + Responses: {}, + UnprocessedKeys: { TableName: { Keys: [{ key: "a" }] } }, + }); + + const controller = new AbortController(); + const options = { TableName: "TableName", Keys: [{ key: "a" }] }; + const stream = await awsDynamoDBGetItemStream(options, { + signal: controller.signal, + }); + + const consuming = streamToArray(stream); + // Let the first throttled batch settle and enter the backoff timer, then + // abort. The backoff timeout was given `{ signal }`, so it must reject + // promptly. A `{}` mutant (dropping the signal) would let the backoff run to + // completion and keep retrying, so this would hang instead of rejecting. + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + +// *** backoff floor: the first retry waits at least 50ms (not 3^0 == 1ms) *** // +test(`${variant}: awsDynamoDBGetItemStream first retry backoff is floored at 50ms`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand) + .resolvesOnce({ + Responses: {}, + UnprocessedKeys: { TableName: { Keys: [{ key: "a" }] } }, + }) + .resolves({ + Responses: { TableName: [{ key: "a", value: 1 }] }, + UnprocessedKeys: {}, + }); + + const options = { TableName: "TableName", Keys: [{ key: "a" }] }; + const stream = await awsDynamoDBGetItemStream(options); + const consuming = streamToArray(stream); + + // Let the first (throttled) batch settle and enter the backoff timer. + await new Promise((resolve) => setImmediate(resolve)); + // 3^0 == 1ms would already fire; the 50ms floor means the retry must still be + // pending at 1ms (kills the Math.max -> Math.min floor mutant and the + // arrow-body removal). + t.mock.timers.tick(1); + await new Promise((resolve) => setImmediate(resolve)); + deepStrictEqual(client.commandCalls(BatchGetItemCommand).length, 1); + + t.mock.timers.tick(49); + const output = await consuming; + deepStrictEqual(output, [{ key: "a", value: 1 }]); + deepStrictEqual(client.commandCalls(BatchGetItemCommand).length, 2); +}); + +// *** setClient swap *** // +test(`${variant}: awsDynamoDBSetClient routes subsequent sends to the new client`, async (_t) => { + const first = mockClient(DynamoDBClient); + awsDynamoDBSetClient(first); + first.on(QueryCommand).resolves({ Items: [{ key: "stale" }] }); + + const second = mockClient(DynamoDBClient); + second.on(QueryCommand).resolves({ Items: [{ key: "fresh" }] }); + awsDynamoDBSetClient(second); + + const stream = await awsDynamoDBQueryStream({ TableName: "T" }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "fresh" }]); + deepStrictEqual(second.commandCalls(QueryCommand).length, 1); + deepStrictEqual(first.commandCalls(QueryCommand).length, 0); +}); + +// setClient must STORE the passed client; a plain stub (prototype-mock-proof) +// proves the stored reference is used. A `setClient(){}` mutant leaves the prior +// client in place. +test(`${variant}: awsDynamoDBSetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { Items: [{ key: "stub" }] }; + }, + }; + awsDynamoDBSetClient(stub); + + const stream = await awsDynamoDBQueryStream({ TableName: "T" }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "stub" }]); + deepStrictEqual(calls, 1); +}); + +// *** abortSignal forwarding on each read command (kills the `{}` options mutant) *** // +test(`${variant}: awsDynamoDBQueryStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client.on(QueryCommand).resolves({ Items: [{ key: "a" }] }); + + const controller = new AbortController(); + const stream = await awsDynamoDBQueryStream( + { TableName: "T" }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(QueryCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +test(`${variant}: awsDynamoDBScanStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client.on(ScanCommand).resolves({ Items: [{ key: "a" }] }); + + const controller = new AbortController(); + const stream = await awsDynamoDBScanStream( + { TableName: "T" }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(ScanCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +test(`${variant}: awsDynamoDBExecuteStatementStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client.on(ExecuteStatementCommand).resolves({ Items: [{ key: "a" }] }); + + const controller = new AbortController(); + const stream = await awsDynamoDBExecuteStatementStream( + { Statement: "SELECT" }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(ExecuteStatementCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +test(`${variant}: awsDynamoDBGetItemStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand) + .resolves({ Responses: { T: [{ key: "a" }] }, UnprocessedKeys: {} }); + + const controller = new AbortController(); + const stream = await awsDynamoDBGetItemStream( + { TableName: "T", Keys: [{ key: "a" }] }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(BatchGetItemCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +// *** executeStatement yields nothing when the response has no Items field *** // +test(`${variant}: awsDynamoDBExecuteStatementStream yields nothing for a response without Items`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // No Items field at all: the `response.Items ?? []` default must be an EMPTY + // array. A `["Stryker was here"]` mutant would yield that sentinel. + client.on(ExecuteStatementCommand).resolves({}); + + const stream = await awsDynamoDBExecuteStatementStream({ + Statement: "SELECT", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, []); +}); + +// *** Keys boundary: exactly 100 is allowed (strict `>`) *** // +test(`${variant}: awsDynamoDBGetItemStream allows exactly 100 Keys`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand) + .resolves({ Responses: { T: [] }, UnprocessedKeys: {} }); + + // Exactly 100 must NOT throw (strict `>`); a `>=` mutant would reject it. + const options = { TableName: "T", Keys: new Array(100).fill({ key: "x" }) }; + const stream = await awsDynamoDBGetItemStream(options); + const output = await streamToArray(stream); + deepStrictEqual(output, []); +}); + +// *** getItem with no Keys (optional chaining on options.Keys) *** // +test(`${variant}: awsDynamoDBGetItemStream tolerates options without Keys`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand) + .resolves({ Responses: { T: [{ key: "a" }] }, UnprocessedKeys: {} }); + + // No Keys property: the `options.Keys?.length > 100` guard must short-circuit + // via the optional chain. A non-optional `options.Keys.length` mutant throws. + const stream = await awsDynamoDBGetItemStream({ TableName: "T" }); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ key: "a" }]); +}); + +// *** getItem tolerates a response missing Responses / UnprocessedKeys *** // +test(`${variant}: awsDynamoDBGetItemStream tolerates a response with neither Responses nor UnprocessedKeys`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Bare response: `response.Responses?.[T]` and `response?.UnprocessedKeys?.[T]?.Keys` + // optional chains must yield [] (no items, no retry). Non-optional mutants + // (`response.Responses[T]`, `response.UnprocessedKeys`, `response?.UnprocessedKeys[T]`) + // would throw a TypeError reading a property of undefined. + client.on(BatchGetItemCommand).resolves({}); + + const stream = await awsDynamoDBGetItemStream({ + TableName: "T", + Keys: [{ key: "a" }], + }); + const output = await streamToArray(stream); + deepStrictEqual(output, []); +}); + +// *** getItem retry accounting: retryMaxCount honoured, error cause populated *** // +test(`${variant}: awsDynamoDBGetItemStream throws after exactly retryMaxCount retries with cause`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Always throttled. retryMaxCount 1: attempt #1 (retryCount 0, 0 >= 1 false -> + // backoff, retryCount -> 1), attempt #2 (1 >= 1 true -> throw). Exactly 2 calls. + // A `>` mutant would need retryCount > 1 (3 calls); a `--` mutant on the + // counter would never reach the cap (retry forever -> timeout). + client.on(BatchGetItemCommand).resolves({ + Responses: {}, + UnprocessedKeys: { T: { Keys: [{ key: "a" }] } }, + }); + + await rejects( + async () => + streamToArray( + await awsDynamoDBGetItemStream({ + TableName: "T", + Keys: [{ key: "a" }], + retryMaxCount: 1, + }), + ), + (error) => { + deepStrictEqual( + error.message, + "awsDynamoDBBatchGetItem has UnprocessedKeys", + ); + // `{}` mutants on the error options / cause would drop these fields. + deepStrictEqual(error.cause.UnprocessedKeysCount, 1); + deepStrictEqual(error.cause.TableName, "T"); + return true; + }, + ); + deepStrictEqual(client.commandCalls(BatchGetItemCommand).length, 2); +}); + +// *** getItem: retryMaxCount ?? 10 must use nullish coalescing (not &&) *** // +test(`${variant}: awsDynamoDBGetItemStream honours an explicit retryMaxCount of 2`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // retryMaxCount 2: `2 ?? 10` === 2 (throw after 3 calls). A `2 && 10` mutant + // yields 10 -> 11 calls (and a huge cumulative backoff -> timeout). Assert the + // exact call count pins the coalescing operator. + client.on(BatchGetItemCommand).resolves({ + Responses: {}, + UnprocessedKeys: { T: { Keys: [{ key: "a" }] } }, + }); + + await rejects( + async () => + streamToArray( + await awsDynamoDBGetItemStream({ + TableName: "T", + Keys: [{ key: "a" }], + retryMaxCount: 2, + }), + ), + { message: "awsDynamoDBBatchGetItem has UnprocessedKeys" }, + ); + deepStrictEqual(client.commandCalls(BatchGetItemCommand).length, 3); +}); + +// *** batchWrite: response with no UnprocessedItems is a success *** // +test(`${variant}: awsDynamoDBPutItemStream treats a response without UnprocessedItems as success`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Bare response (no UnprocessedItems): the `UnprocessedItems?.[T]?.length` + // optional chain must be falsy (no retry). A non-optional mutant throws. + client.on(BatchWriteItemCommand).resolves({}); + + const output = await pipeline([ + createReadableStream([{ key: "a", value: 1 }]), + awsDynamoDBPutItemStream({ TableName: "T" }), + ]); + deepStrictEqual(output, {}); + deepStrictEqual(client.commandCalls(BatchWriteItemCommand).length, 1); +}); + +// *** batchWrite retry accounting: retryMaxCount honoured, counter increments, +// error cause populated, recursion uses retryCount + 1 *** // +test(`${variant}: awsDynamoDBPutItemStream throws after exactly retryMaxCount retries with cause`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Always returns UnprocessedItems. retryMaxCount 1 -> throw after 2 calls. + // `>` mutant -> 3 calls; `retryCount + 1` -> `- 1` mutant never reaches the cap + // (recurses forever -> timeout). + client.on(BatchWriteItemCommand).resolves({ + UnprocessedItems: { T: [{ PutRequest: { Item: { key: "a" } } }] }, + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ key: "a" }]), + awsDynamoDBPutItemStream({ TableName: "T", retryMaxCount: 1 }), + ]), + (error) => { + deepStrictEqual( + error.message, + "awsDynamoDBBatchWriteItem has UnprocessedItems", + ); + deepStrictEqual(error.cause.UnprocessedItemsCount, 1); + deepStrictEqual(error.cause.TableName, "T"); + return true; + }, + ); + deepStrictEqual(client.commandCalls(BatchWriteItemCommand).length, 2); +}); + +test(`${variant}: awsDynamoDBPutItemStream honours an explicit retryMaxCount of 2`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // retryMaxCount 2: `2 ?? 10` === 2 -> throw after 3 calls. A `2 && 10` mutant + // yields 10 -> many more calls (huge backoff -> timeout). + client.on(BatchWriteItemCommand).resolves({ + UnprocessedItems: { T: [{ PutRequest: { Item: { key: "a" } } }] }, + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ key: "a" }]), + awsDynamoDBPutItemStream({ TableName: "T", retryMaxCount: 2 }), + ]), + { message: "awsDynamoDBBatchWriteItem has UnprocessedItems" }, + ); + deepStrictEqual(client.commandCalls(BatchWriteItemCommand).length, 3); +}); + test(`${variant}: default export should include all stream functions`, (_t) => { deepStrictEqual(Object.keys(dynamodbDefault).sort(), [ "deleteItemStream", diff --git a/packages/aws/glue-schema-registry.d.ts b/packages/aws/glue-schema-registry.d.ts new file mode 100644 index 0000000..99ae352 --- /dev/null +++ b/packages/aws/glue-schema-registry.d.ts @@ -0,0 +1,17 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT + +export interface GlueSchemaVersion { + schemaVersionId: string; + schemaDefinition: string; + dataFormat: string; +} + +export function awsGlueSchemaRegistrySetClient(client: unknown): void; + +export function awsGlueSchemaRegistryResolver(options?: { + client?: unknown; + clientOptions?: Record; + cacheExpiry?: number; + maxCacheSize?: number; +}): (schemaVersionId: string) => Promise; diff --git a/packages/aws/glue-schema-registry.js b/packages/aws/glue-schema-registry.js new file mode 100644 index 0000000..a085978 --- /dev/null +++ b/packages/aws/glue-schema-registry.js @@ -0,0 +1,96 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { GetSchemaVersionCommand, GlueClient } from "@aws-sdk/client-glue"; +import { awsClientDefaults } from "./client.js"; + +let defaultClient; + +export const awsGlueSchemaRegistrySetClient = (glueClient) => { + defaultClient = glueClient; +}; + +export const awsGlueSchemaRegistryResolver = ({ + client, + clientOptions, + cacheExpiry = -1, + maxCacheSize = 1000, +} = {}) => { + // Cache the lazily-created client per resolver invocation (closure-local) so + // each resolver honors its own clientOptions. The module-level defaultClient + // is reserved strictly for the explicit setClient() path. + let lazyClient; + const resolveClient = () => { + if (client) return client; + if (defaultClient) return defaultClient; + lazyClient ??= new GlueClient({ + ...awsClientDefaults, + ...clientOptions, + }); + return lazyClient; + }; + + // Map preserves insertion order, so we get O(1) FIFO eviction by deleting + // the oldest key when we hit the cap. + const cache = new Map(); // schemaVersionId -> { value, expires } + // Dedup in-flight lookups so parallel resolves for the same id don't fan + // out to N Glue calls (Glue rate-limits aggressively). + const inflight = new Map(); // schemaVersionId -> Promise + + const evictIfNeeded = () => { + while (cache.size >= maxCacheSize) { + const oldest = cache.keys().next().value; + cache.delete(oldest); + } + }; + + return async (schemaVersionId) => { + if (typeof schemaVersionId !== "string" || schemaVersionId.length === 0) { + throw new TypeError( + "awsGlueSchemaRegistryResolver: schemaVersionId required", + ); + } + const hit = cache.get(schemaVersionId); + // expires === -1 is the never-expires sentinel; otherwise it is an absolute + // timestamp and the entry is fresh while it is still in the future. + if (hit && (hit.expires === -1 || hit.expires > Date.now())) { + return hit.value; + } + // Drop the stale entry so it doesn't sit ahead of fresh keys in the + // Map's FIFO order and get re-inserted at the tail below. delete() is a + // no-op when the key is absent, so the (stale-only) hit is implied. + cache.delete(schemaVersionId); + const pending = inflight.get(schemaVersionId); + if (pending) return pending; + + const promise = (async () => { + try { + const resp = await resolveClient().send( + new GetSchemaVersionCommand({ SchemaVersionId: schemaVersionId }), + ); + const value = { + schemaVersionId: resp.SchemaVersionId, + schemaDefinition: resp.SchemaDefinition, + dataFormat: resp.DataFormat, + }; + // delete-then-set so a refreshed key lands at the tail of the + // Map's insertion order, keeping eviction true-FIFO. + cache.delete(schemaVersionId); + evictIfNeeded(); + cache.set(schemaVersionId, { + value, + expires: cacheExpiry < 0 ? -1 : Date.now() + cacheExpiry, + }); + return value; + } finally { + inflight.delete(schemaVersionId); + } + })(); + inflight.set(schemaVersionId, promise); + return promise; + }; +}; + +export default { + setClient: awsGlueSchemaRegistrySetClient, + resolver: awsGlueSchemaRegistryResolver, +}; diff --git a/packages/aws/glue-schema-registry.test.js b/packages/aws/glue-schema-registry.test.js new file mode 100644 index 0000000..23dad35 --- /dev/null +++ b/packages/aws/glue-schema-registry.test.js @@ -0,0 +1,354 @@ +import { deepStrictEqual, rejects, strictEqual } from "node:assert"; +import test from "node:test"; +import { GetSchemaVersionCommand, GlueClient } from "@aws-sdk/client-glue"; +import glueDefault, { + awsGlueSchemaRegistryResolver, + awsGlueSchemaRegistrySetClient, +} from "@datastream/aws/glue-schema-registry"; +import { mockClient } from "aws-sdk-client-mock"; + +let variant = "unknown"; +for (const execArgv of process.execArgv) { + const flag = "--conditions="; + if (execArgv.includes(flag)) { + variant = execArgv.replace(flag, ""); + } +} + +test(`${variant}: awsGlueSchemaRegistryResolver fetches and returns schema metadata`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "v1", + SchemaDefinition: 'syntax = "proto3";', + DataFormat: "PROTOBUF", + }); + + const resolve = awsGlueSchemaRegistryResolver(); + const result = await resolve("v1"); + strictEqual(result.schemaVersionId, "v1"); + strictEqual(result.dataFormat, "PROTOBUF"); + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 1); +}); + +test(`${variant}: awsGlueSchemaRegistryResolver caches by schemaVersionId`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + + const resolve = awsGlueSchemaRegistryResolver({ cacheExpiry: -1 }); + await resolve("v1"); + await resolve("v1"); + await resolve("v1"); + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 1); +}); + +test(`${variant}: awsGlueSchemaRegistryResolver respects cacheExpiry`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + + const resolve = awsGlueSchemaRegistryResolver({ cacheExpiry: 1 }); + await resolve("v1"); + await new Promise((r) => setTimeout(r, 5)); + await resolve("v1"); + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 2); +}); + +test(`${variant}: awsGlueSchemaRegistryResolver dedupes concurrent in-flight lookups`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + let pendingResolve; + client.on(GetSchemaVersionCommand).callsFake( + () => + new Promise((r) => { + pendingResolve = () => + r({ + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + }), + ); + + const resolve = awsGlueSchemaRegistryResolver(); + // Fire three parallel lookups before any settle. + const p1 = resolve("v1"); + const p2 = resolve("v1"); + const p3 = resolve("v1"); + // Only one Glue command should be in flight. + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 1); + pendingResolve(); + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + strictEqual(r1, r2); + strictEqual(r2, r3); + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 1); +}); + +test(`${variant}: awsGlueSchemaRegistryResolver honors per-resolver clientOptions on the lazy-init path`, async () => { + // Clear any module-level client set by earlier tests so the LAZY-init path + // (no explicit client passed) is exercised for both resolvers. + awsGlueSchemaRegistrySetClient(undefined); + + // mockClient stubs .send on every GlueClient instance while leaving the real + // constructor intact, so each lazily-built client still carries the region + // it was constructed with. callsFake receives the live client instance so we + // can read back which client actually serviced each lookup. + const mock = mockClient(GlueClient); + const seenRegions = []; + mock.on(GetSchemaVersionCommand).callsFake(async (input, getClient) => { + const region = await getClient().config.region(); + seenRegions.push(region); + return { + SchemaVersionId: input.SchemaVersionId, + SchemaDefinition: "x", + DataFormat: "AVRO", + }; + }); + + // First resolver triggers lazy construction with us-east-1. + const resolveA = awsGlueSchemaRegistryResolver({ + clientOptions: { region: "us-east-1" }, + }); + // Second resolver is created with a DIFFERENT region. It must build/use its + // own client, not silently reuse the first resolver's lazily-built client. + const resolveB = awsGlueSchemaRegistryResolver({ + clientOptions: { region: "eu-west-1" }, + }); + + await resolveA("v1"); + await resolveB("v2"); + + deepStrictEqual(seenRegions, ["us-east-1", "eu-west-1"]); +}); + +test(`${variant}: awsGlueSchemaRegistryResolver evicts oldest entry past maxCacheSize`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).callsFake((args) => + Promise.resolve({ + SchemaVersionId: args.SchemaVersionId, + SchemaDefinition: "x", + DataFormat: "AVRO", + }), + ); + + const resolve = awsGlueSchemaRegistryResolver({ maxCacheSize: 2 }); + await resolve("v1"); + await resolve("v2"); + await resolve("v3"); // evicts v1 + // v1 should miss now and re-fetch + await resolve("v1"); + const calls = client.commandCalls(GetSchemaVersionCommand); + strictEqual(calls.length, 4); // v1, v2, v3, v1-again + strictEqual(calls[3].args[0].input.SchemaVersionId, "v1"); +}); + +// *** setClient routes lookups to the configured default client *** // +test(`${variant}: awsGlueSchemaRegistrySetClient default client services resolvers without an explicit client`, async () => { + awsGlueSchemaRegistrySetClient(undefined); + + // A resolver with neither `client` nor a default would lazily build a real + // GlueClient. Setting a default mock makes it the one that handles the call. + const def = mockClient(GlueClient); + def.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + awsGlueSchemaRegistrySetClient(def); + + const resolve = awsGlueSchemaRegistryResolver(); + const result = await resolve("v1"); + strictEqual(result.schemaVersionId, "v1"); + strictEqual(def.commandCalls(GetSchemaVersionCommand).length, 1); +}); + +// *** explicit client takes precedence over the module default *** // +test(`${variant}: awsGlueSchemaRegistryResolver prefers an explicit client over the default`, async () => { + const def = mockClient(GlueClient); + def.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "from-default", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + awsGlueSchemaRegistrySetClient(def); + + // Build a separate, explicit client instance with its own stub. + const explicit = new GlueClient({ region: "us-east-1" }); + explicit.send = async () => ({ + SchemaVersionId: "from-explicit", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + + const resolve = awsGlueSchemaRegistryResolver({ client: explicit }); + const result = await resolve("v1"); + // The explicit client serviced the lookup, not the module default. + strictEqual(result.schemaVersionId, "from-explicit"); + strictEqual(def.commandCalls(GetSchemaVersionCommand).length, 0); +}); + +// *** schemaVersionId validation *** // +test(`${variant}: awsGlueSchemaRegistryResolver rejects a non-string id`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ SchemaVersionId: "v1" }); + + const resolve = awsGlueSchemaRegistryResolver(); + await rejects(() => resolve(123), { + name: "TypeError", + message: "awsGlueSchemaRegistryResolver: schemaVersionId required", + }); + await rejects(() => resolve(undefined), { + name: "TypeError", + message: "awsGlueSchemaRegistryResolver: schemaVersionId required", + }); + // No Glue call for invalid input. + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 0); +}); + +test(`${variant}: awsGlueSchemaRegistryResolver rejects an empty-string id`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ SchemaVersionId: "" }); + + const resolve = awsGlueSchemaRegistryResolver(); + await rejects(() => resolve(""), { + name: "TypeError", + message: "awsGlueSchemaRegistryResolver: schemaVersionId required", + }); + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 0); +}); + +// *** default cacheExpiry (-1) means cache never expires *** // +test(`${variant}: awsGlueSchemaRegistryResolver default cacheExpiry caches indefinitely`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + + // No cacheExpiry option => default -1 (sentinel for "never expires"). Even + // after a real delay the entry must still be served from cache. A `+1` + // mutant on the default would give a 1ms TTL and force a re-fetch. + const resolve = awsGlueSchemaRegistryResolver(); + await resolve("v1"); + await new Promise((r) => setTimeout(r, 10)); + await resolve("v1"); + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 1); +}); + +// *** positive cacheExpiry serves from cache within the TTL window *** // +test(`${variant}: awsGlueSchemaRegistryResolver serves from cache within a positive TTL`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + + // A generous 60s TTL: a second immediate lookup is within the window so it + // must hit the cache (expires = Date.now() + cacheExpiry, in the future). A + // `Date.now() - cacheExpiry` mutant would put expiry in the past -> re-fetch. + const resolve = awsGlueSchemaRegistryResolver({ cacheExpiry: 60_000 }); + await resolve("v1"); + await resolve("v1"); + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 1); +}); + +// *** setClient stores the default client used by client-less resolvers *** // +test(`${variant}: awsGlueSchemaRegistrySetClient stores the default client reference`, async () => { + // A plain stub (no GlueClient prototype) proves the stored default is used by + // resolvers that pass neither `client` nor `clientOptions`. A `setClient(){}` + // mutant (or a `if (defaultClient) return defaultClient` -> false mutant) would + // fall through to lazily building a real GlueClient and the stub's send would + // never run. + let calls = 0; + const stub = { + send: async () => { + calls++; + return { + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }; + }, + }; + awsGlueSchemaRegistrySetClient(stub); + + const resolve = awsGlueSchemaRegistryResolver(); + const result = await resolve("v1"); + strictEqual(result.schemaVersionId, "v1"); + strictEqual(calls, 1); + awsGlueSchemaRegistrySetClient(undefined); +}); + +// *** cacheExpiry === 0 is a finite (immediately-stale) TTL, NOT the never-expire +// sentinel: `cacheExpiry < 0` must be strict (`<=` would treat 0 as never-expire) *** // +test(`${variant}: awsGlueSchemaRegistryResolver treats cacheExpiry 0 as a finite TTL`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + + const realNow = Date.now; + let now = 1_000_000; + Date.now = () => now; + try { + const resolve = awsGlueSchemaRegistryResolver({ cacheExpiry: 0 }); + await resolve("v1"); // expires = now + 0 = now + now += 1; // advance time so the entry is strictly in the past + await resolve("v1"); // expired -> re-fetch + // `cacheExpiry <= 0` mutant would store -1 (never-expire) -> only 1 call. + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 2); + } finally { + Date.now = realNow; + } +}); + +// *** freshness boundary is strict `>` Date.now() (an entry expiring exactly now +// is stale): a `>=` mutant would serve it from cache *** // +test(`${variant}: awsGlueSchemaRegistryResolver re-fetches an entry whose expiry equals now`, async () => { + const client = mockClient(GlueClient); + awsGlueSchemaRegistrySetClient(client); + client.on(GetSchemaVersionCommand).resolves({ + SchemaVersionId: "v1", + SchemaDefinition: "x", + DataFormat: "AVRO", + }); + + const realNow = Date.now; + const now = 2_000_000; + Date.now = () => now; // frozen: expires (now + 0) === Date.now() + try { + const resolve = awsGlueSchemaRegistryResolver({ cacheExpiry: 0 }); + await resolve("v1"); // expires = now + await resolve("v1"); // hit.expires (now) > Date.now() (now) === false -> stale + // `>=` mutant: now >= now is true -> served from cache -> only 1 call. + strictEqual(client.commandCalls(GetSchemaVersionCommand).length, 2); + } finally { + Date.now = realNow; + } +}); + +// *** default export shape *** // +test(`${variant}: glue default export exposes setClient and resolver`, () => { + deepStrictEqual(Object.keys(glueDefault).sort(), ["resolver", "setClient"]); + strictEqual(glueDefault.setClient, awsGlueSchemaRegistrySetClient); + strictEqual(glueDefault.resolver, awsGlueSchemaRegistryResolver); +}); diff --git a/packages/aws/kinesis.js b/packages/aws/kinesis.js index aa82d64..28f4825 100644 --- a/packages/aws/kinesis.js +++ b/packages/aws/kinesis.js @@ -5,9 +5,51 @@ import { KinesisClient, PutRecordsCommand, } from "@aws-sdk/client-kinesis"; -import { createWritableStream } from "@datastream/core"; +import { createWritableStream, timeout } from "@datastream/core"; import { awsClientDefaults } from "./client.js"; +// PutRecords limits: <=500 records, <=5 MiB aggregate, <=1 MiB per record. +const KINESIS_MAX_RECORDS = 500; +const KINESIS_MAX_RECORD_BYTES = 1024 * 1024; // 1 MiB +// 5 MiB aggregate with headroom for request framing. +const KINESIS_MAX_BATCH_BYTES = 5 * 1024 * 1024 - 64 * 1024; + +// Partial failures are overwhelmingly throttling-driven +// (ProvisionedThroughputExceeded); a near-zero early delay (3^0 == 1ms) just +// hammers the throttled stream. Apply a floor so the first retries give +// capacity time to recover, while preserving the ~59sec cap (3^10). +const BACKOFF_FLOOR_MS = 50; +const BACKOFF_CAP_MS = 3 ** 10; +// streamOptions is always supplied by the exported stream function (defaulting +// to {}), so it is never nullish here. +const backoff = (retryCount, streamOptions) => + timeout( + Math.min(BACKOFF_CAP_MS, Math.max(BACKOFF_FLOOR_MS, 3 ** retryCount)), + { + signal: streamOptions.signal, + }, + ); + +const _textEncoder = new TextEncoder(); +const _byteLength = (value) => + typeof value === "string" + ? _textEncoder.encode(value).byteLength + : value.byteLength; + +const recordByteLength = (record) => { + let bytes = 0; + if (record.Data != null) { + bytes += _byteLength(record.Data); + } + if (record.PartitionKey != null) { + bytes += _byteLength(record.PartitionKey); + } + if (record.ExplicitHashKey != null) { + bytes += _byteLength(record.ExplicitHashKey); + } + return bytes; +}; + let client = new KinesisClient(awsClientDefaults); export const awsKinesisSetClient = (kinesisClient) => { client = kinesisClient; @@ -32,7 +74,9 @@ export const awsKinesisGetRecordsStream = async ( expectMore = opts.ShardIterator !== null && (pollingActive || records.length > 0); if (pollingActive && records.length === 0 && pollingDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, pollingDelay)); + // Abortable idle wait: rejects immediately and clears the timer + // when streamOptions.signal aborts mid-delay. + await timeout(pollingDelay, { signal: streamOptions.signal }); } } } @@ -40,19 +84,65 @@ export const awsKinesisGetRecordsStream = async ( }; export const awsKinesisPutRecordsStream = (options, streamOptions = {}) => { + const { retryMaxCount = 10, ...putOptions } = options; let batch = []; - const send = () => { - options.Records = batch; + let batchBytes = 0; + const send = async () => { + if (!batch.length) { + return; + } + let records = batch; batch = []; - return client.send(new PutRecordsCommand(options)); + batchBytes = 0; + let retryCount = 0; + while (true) { + const response = await client.send( + new PutRecordsCommand({ ...putOptions, Records: records }), + { abortSignal: streamOptions.signal }, + ); + if (!response.FailedRecordCount) { + return; + } + // Retry only the records whose result entry carries an ErrorCode. When + // the response omits the per-record Records array there is nothing to + // inspect, so there is nothing to retry. + const results = response.Records; + if (!results) { + return; + } + const failed = records.filter( + (_record, index) => results[index]?.ErrorCode, + ); + if (!failed.length) { + return; + } + if (retryCount >= retryMaxCount) { + throw new Error("awsKinesisPutRecords has failed records", { + cause: results.filter((result) => result.ErrorCode), + }); + } + await backoff(retryCount, streamOptions); + retryCount++; + records = failed; + } }; const write = async (chunk) => { - if (batch.length === 500) { + const chunkBytes = recordByteLength(chunk); + if (chunkBytes > KINESIS_MAX_RECORD_BYTES) { + throw new Error("awsKinesisPutRecords record exceeds 1MiB limit", { + cause: { bytes: chunkBytes, limit: KINESIS_MAX_RECORD_BYTES }, + }); + } + if ( + batch.length === KINESIS_MAX_RECORDS || + (batch.length && batchBytes + chunkBytes > KINESIS_MAX_BATCH_BYTES) + ) { await send(); } batch.push(chunk); + batchBytes += chunkBytes; }; - const final = () => (batch.length ? send() : undefined); + const final = () => send(); return createWritableStream(write, final, streamOptions); }; diff --git a/packages/aws/kinesis.test.js b/packages/aws/kinesis.test.js index 1ee45a5..9e7ff23 100644 --- a/packages/aws/kinesis.test.js +++ b/packages/aws/kinesis.test.js @@ -1,11 +1,11 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import test from "node:test"; import { GetRecordsCommand, KinesisClient, PutRecordsCommand, } from "@aws-sdk/client-kinesis"; -import { +import kinesisDefault, { awsKinesisGetRecordsStream, awsKinesisPutRecordsStream, awsKinesisSetClient, @@ -189,6 +189,229 @@ test(`${variant}: awsKinesisPutRecordsStream should handle empty input`, async ( deepStrictEqual(result, {}); }); +// *** PutRecords partial failure (data loss) *** // +test(`${variant}: awsKinesisPutRecordsStream should retry failed records on partial failure`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + + const input = [ + { Data: "a", PartitionKey: "a" }, + { Data: "b", PartitionKey: "b" }, + ]; + const options = { StreamName: "test-stream" }; + + client + .on(PutRecordsCommand) + .resolvesOnce({ + FailedRecordCount: 1, + Records: [ + { SequenceNumber: "1" }, + { ErrorCode: "ProvisionedThroughputExceededException" }, + ], + }) + .resolvesOnce({ + FailedRecordCount: 0, + Records: [{ SequenceNumber: "2" }], + }); + + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]; + await pipeline(stream); + + const calls = client.commandCalls(PutRecordsCommand); + deepStrictEqual(calls.length, 2); + // The retry must resubmit only the failed record (b) + deepStrictEqual(calls[1].args[0].input.Records, [ + { Data: "b", PartitionKey: "b" }, + ]); +}); + +test(`${variant}: awsKinesisPutRecordsStream should throw when records keep failing`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + + const input = [{ Data: "a", PartitionKey: "a" }]; + const options = { StreamName: "test-stream", retryMaxCount: 0 }; + + client.on(PutRecordsCommand).resolves({ + FailedRecordCount: 1, + Records: [{ ErrorCode: "InternalFailure", ErrorMessage: "boom" }], + }); + + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]; + await rejects(() => pipeline(stream), { + message: "awsKinesisPutRecords has failed records", + }); +}); + +test(`${variant}: awsKinesisPutRecordsStream should flush when aggregate bytes exceed 5MiB`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // 8 records just under 1 MiB each => ~8 MiB total, well below 500 count + // but over the 5 MiB aggregate limit, forcing multiple flushes. + const big = "x".repeat(1024 * 1024 - 1024); + const input = Array.from({ length: 8 }, (_v, i) => ({ + Data: big, + PartitionKey: `${i}`, + })); + const options = { StreamName: "test-stream" }; + + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]; + await pipeline(stream); + + const calls = client.commandCalls(PutRecordsCommand); + // ~8 MiB cannot fit in a single 5 MiB request -> multiple flushes + deepStrictEqual(calls.length > 1, true); + // And no single flush may carry more than the 5 MiB aggregate + for (const call of calls) { + deepStrictEqual(call.args[0].input.Records.length <= 5, true); + } +}); + +test(`${variant}: awsKinesisPutRecordsStream should count ExplicitHashKey toward record size`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + const input = [ + { Data: "a", PartitionKey: "p", ExplicitHashKey: "123456789" }, + ]; + const options = { StreamName: "test-stream" }; + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]; + await pipeline(stream); + + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 1); +}); + +test(`${variant}: awsKinesisPutRecordsStream should not retry when FailedRecordCount has no ErrorCode entries`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // Defensive: count says failures but no entry carries an ErrorCode. + client.on(PutRecordsCommand).resolves({ + FailedRecordCount: 1, + Records: [{ SequenceNumber: "1" }], + }); + + const input = [{ Data: "a", PartitionKey: "a" }]; + const options = { StreamName: "test-stream" }; + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]; + await pipeline(stream); + + // No ErrorCode => no retry + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 1); +}); + +test(`${variant}: awsKinesisPutRecordsStream should reject a single record over 1MiB`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + const tooBig = "x".repeat(1024 * 1024 + 1); + const input = [{ Data: tooBig, PartitionKey: "k" }]; + const options = { StreamName: "test-stream" }; + + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]; + await rejects(() => pipeline(stream), { + message: "awsKinesisPutRecords record exceeds 1MiB limit", + }); +}); + +test(`${variant}: awsKinesisPutRecordsStream should not mutate caller options`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + const options = { StreamName: "test-stream" }; + const optionsCopy = { ...options }; + const input = [{ Data: "a", PartitionKey: "a" }]; + + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]; + await pipeline(stream); + + deepStrictEqual(options, optionsCopy); +}); + +test(`${variant}: awsKinesisPutRecordsStream should forward abort signal to client.send`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + const controller = new AbortController(); + const input = [{ Data: "a", PartitionKey: "a" }]; + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream( + { StreamName: "test-stream" }, + { signal: controller.signal }, + ), + ]; + await pipeline(stream); + + const calls = client.commandCalls(PutRecordsCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +test(`${variant}: awsKinesisGetRecordsStream should abort the idle poll delay on signal`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + + // Always empty so the poller enters the idle pollingDelay wait. + client + .on(GetRecordsCommand) + .resolves({ Records: [], NextShardIterator: "i" }); + + const controller = new AbortController(); + const options = { + ShardIterator: "iter1", + pollingActive: true, + // Large delay: if the timer is not abort-aware the consumer would hang + // far longer than this test's window. + pollingDelay: 60_000, + }; + const stream = await awsKinesisGetRecordsStream(options, { + signal: controller.signal, + }); + + const consuming = (async () => { + for await (const _item of stream) { + // no records ever arrive + } + })(); + + // Let the generator reach the idle delay, then abort. The abortable timeout + // must reject promptly instead of waiting out the 60s delay. + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + // *** AbortSignal *** // test(`${variant}: awsKinesisGetRecordsStream should pass signal to client`, async (_t) => { const client = mockClient(KinesisClient); @@ -208,3 +431,707 @@ test(`${variant}: awsKinesisGetRecordsStream should pass signal to client`, asyn const calls = client.commandCalls(GetRecordsCommand); deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); }); + +// *** Batch byte-cap boundary (KINESIS_MAX_BATCH_BYTES = 5MiB - 64KiB) *** // +test(`${variant}: awsKinesisPutRecordsStream splits at the 5MiB-64KiB aggregate cap, not 5MiB`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // Each record is 128KiB Data + 1-byte PartitionKey = 131,073 bytes, chosen so + // the real cap (5MiB - 64KiB = 5,177,344) and a mutated `+64KiB` cap + // (5,308,416) disagree on where to split: + // real cap: 39 records = 5,111,847 (fits); 40th = 5,242,920 (> cap) + // -> flush after 39 + // +64KiB: 40 records = 5,242,920 (fits); 41st = 5,373,993 (> cap) + // -> flush after 40 + // Multiplicative mutants (`*1024/1024`, `5/1024*1024`) drive the cap negative + // so every record after the first flushes -> a completely different shape. + const each = 128 * 1024; // 131,072 bytes of Data + const data = "x".repeat(each); + const input = Array.from({ length: 41 }, (_v, i) => ({ + Data: data, + PartitionKey: `${i % 10}`, // single-char key => 1 byte + })); + const options = { StreamName: "test-stream" }; + await pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]); + + const calls = client.commandCalls(PutRecordsCommand); + deepStrictEqual(calls.length, 2); + // The true cap splits after exactly 39 records. + deepStrictEqual(calls[0].args[0].input.Records.length, 39); + deepStrictEqual(calls[1].args[0].input.Records.length, 2); +}); + +test(`${variant}: awsKinesisPutRecordsStream keeps two small records under the cap in one batch`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // Two tiny records: well under both the count and byte caps -> single flush. + const input = [ + { Data: "a", PartitionKey: "1" }, + { Data: "b", PartitionKey: "2" }, + ]; + await pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + + const calls = client.commandCalls(PutRecordsCommand); + deepStrictEqual(calls.length, 1); + deepStrictEqual(calls[0].args[0].input.Records.length, 2); +}); + +// *** Per-record 1MiB boundary (strict `>` not `>=`) *** // +test(`${variant}: awsKinesisPutRecordsStream accepts a record exactly at the 1MiB limit`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // Exactly 1 MiB of single-byte chars (1 byte each in UTF-8), no PartitionKey, + // so byteLength === KINESIS_MAX_RECORD_BYTES. With strict `>` this is allowed; + // a `>=` mutant would wrongly reject it. + const exact = "x".repeat(1024 * 1024); + const input = [{ Data: exact }]; + await pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 1); +}); + +// *** recordByteLength counts PartitionKey & ExplicitHashKey *** // +test(`${variant}: awsKinesisPutRecordsStream counts PartitionKey bytes toward the 1MiB limit`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // Data alone is exactly 1MiB (allowed), but adding a non-empty PartitionKey + // pushes the record over 1MiB -> must reject. This proves PartitionKey is + // summed in (the `if (record.PartitionKey != null)` block and `+=`). + const data = "x".repeat(1024 * 1024); + const input = [{ Data: data, PartitionKey: "pk" }]; + await rejects( + () => + pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]), + { message: "awsKinesisPutRecords record exceeds 1MiB limit" }, + ); +}); + +test(`${variant}: awsKinesisPutRecordsStream counts ExplicitHashKey bytes toward the 1MiB limit`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // Data exactly 1MiB; a non-empty ExplicitHashKey tips it over -> reject. + // Proves the ExplicitHashKey branch and its `+=` (a `-=` mutant would keep + // it under the limit and the record would be accepted). + const data = "x".repeat(1024 * 1024); + const input = [{ Data: data, PartitionKey: "p", ExplicitHashKey: "h" }]; + await rejects( + () => + pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]), + { message: "awsKinesisPutRecords record exceeds 1MiB limit" }, + ); +}); + +// *** Failure cause only carries ErrorCode entries *** // +test(`${variant}: awsKinesisPutRecordsStream error cause lists only failed (ErrorCode) records`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + + client.on(PutRecordsCommand).resolves({ + FailedRecordCount: 1, + Records: [ + { SequenceNumber: "ok-1" }, + { ErrorCode: "InternalFailure", ErrorMessage: "boom" }, + ], + }); + + const input = [ + { Data: "a", PartitionKey: "a" }, + { Data: "b", PartitionKey: "b" }, + ]; + const options = { StreamName: "test-stream", retryMaxCount: 0 }; + await rejects( + () => + pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream(options), + ]), + (error) => { + deepStrictEqual(error.message, "awsKinesisPutRecords has failed records"); + // cause must be filtered to only the ErrorCode-bearing entry. + deepStrictEqual(error.cause, [ + { ErrorCode: "InternalFailure", ErrorMessage: "boom" }, + ]); + return true; + }, + ); +}); + +// *** Count cap split at exactly 500 records *** // +test(`${variant}: awsKinesisPutRecordsStream flushes at exactly 500 records`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // 501 tiny records: count cap (500) forces exactly one mid-stream flush of + // 500, then a final flush of 1. Proves `=== 500` (not 499/501) and that the + // count check, not bytes, triggers here. + const input = Array.from({ length: 501 }, (_v, i) => ({ + Data: "x", + PartitionKey: `${i}`, + })); + await pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + + const calls = client.commandCalls(PutRecordsCommand); + deepStrictEqual(calls.length, 2); + deepStrictEqual(calls[0].args[0].input.Records.length, 500); + deepStrictEqual(calls[1].args[0].input.Records.length, 1); +}); + +// *** Backoff floor: first retry waits >= 50ms, not 3^0 == 1ms *** // +test(`${variant}: awsKinesisPutRecordsStream first retry backoff is floored at 50ms`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client + .on(PutRecordsCommand) + .resolvesOnce({ + FailedRecordCount: 1, + Records: [{ ErrorCode: "ProvisionedThroughputExceededException" }], + }) + .resolves({ FailedRecordCount: 0 }); + + const input = [{ Data: "a", PartitionKey: "a" }]; + const consuming = pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + + // Let the first send + failure happen, entering the backoff timer. + await new Promise((resolve) => setImmediate(resolve)); + // 3^0 == 1ms would already have elapsed; the floor means the retry must + // still be pending after 1ms but fire by 50ms. + t.mock.timers.tick(1); + await new Promise((resolve) => setImmediate(resolve)); + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 1); + t.mock.timers.tick(49); + await consuming; + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 2); +}); + +// *** setClient swaps the active client *** // +test(`${variant}: awsKinesisSetClient routes subsequent sends to the new client`, async (_t) => { + const first = mockClient(KinesisClient); + awsKinesisSetClient(first); + first.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + const second = mockClient(KinesisClient); + second.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + // Swap before any send: only `second` should receive the command. If + // setClient's body were removed, the stale `first` would be used. + awsKinesisSetClient(second); + + await pipeline([ + createReadableStream([{ Data: "a", PartitionKey: "a" }]), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + + deepStrictEqual(second.commandCalls(PutRecordsCommand).length, 1); + deepStrictEqual(first.commandCalls(PutRecordsCommand).length, 0); +}); + +// *** GetRecords stops when NextShardIterator becomes null (shard closed) *** // +test(`${variant}: awsKinesisGetRecordsStream stops polling when NextShardIterator is null even with pollingActive`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + + // A closed shard returns a null NextShardIterator: the generator must stop + // regardless of pollingActive. If `ShardIterator !== null` were dropped from + // the expectMore condition, pollingActive would loop forever. + client.on(GetRecordsCommand).resolves({ + Records: [{ Data: "a" }], + NextShardIterator: null, + }); + + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 0, + }; + const stream = await awsKinesisGetRecordsStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ Data: "a" }]); + // Exactly one GetRecords call: the null iterator ends the loop. + deepStrictEqual(client.commandCalls(GetRecordsCommand).length, 1); +}); + +test(`${variant}: awsKinesisGetRecordsStream without pollingActive stops once records run out`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + + // Non-null iterator but empty Records and no pollingActive: expectMore must + // become false (records.length > 0 is false). A surviving mutant that forces + // the loop true would call GetRecords forever. + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [{ Data: "a" }], NextShardIterator: "iter2" }) + .resolves({ Records: [], NextShardIterator: "iter3" }); + + const options = { ShardIterator: "iter1" }; + const stream = await awsKinesisGetRecordsStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ Data: "a" }]); + // First call returns a record (keep going), second returns empty (stop). + deepStrictEqual(client.commandCalls(GetRecordsCommand).length, 2); +}); + +// *** setClient stores the reference (not just prototype-mocked behavior) *** // +test(`${variant}: awsKinesisSetClient stores the passed client reference`, async (_t) => { + // mockClient patches the SDK prototype, hiding instance identity; a plain + // stub proves the stored reference is the one actually used to send. A + // `setClient(){}` mutant would leave the previous client in place and the + // stub's send would never run. + let calls = 0; + const stub = { + send: async () => { + calls++; + return { Records: [{ Data: "stub" }], NextShardIterator: null }; + }, + }; + awsKinesisSetClient(stub); + + const stream = await awsKinesisGetRecordsStream({ ShardIterator: "iter1" }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ Data: "stub" }]); + deepStrictEqual(calls, 1); +}); + +// *** getRecords forwards the caller options (spread, not {}) to the command *** // +test(`${variant}: awsKinesisGetRecordsStream forwards ShardIterator to GetRecords`, async (_t) => { + // Capture the ShardIterator at send time: the generator mutates opts.ShardIterator + // to the (null) NextShardIterator AFTER the await, so we must read it synchronously + // inside send. A `command({})` object-literal mutant starts with an empty opts and + // the first command would carry ShardIterator === undefined. + let firstShardIterator = "unset"; + const stub = { + send: (command) => { + if (firstShardIterator === "unset") { + firstShardIterator = command.input.ShardIterator; + } + return Promise.resolve({ + Records: [{ Data: "a" }], + NextShardIterator: null, + }); + }, + }; + awsKinesisSetClient(stub); + + const stream = await awsKinesisGetRecordsStream({ + ShardIterator: "iter-xyz", + }); + await streamToArray(stream); + + deepStrictEqual(firstShardIterator, "iter-xyz"); +}); + +// *** polling delay guard: each conjunct of the idle-wait condition *** // +test(`${variant}: awsKinesisGetRecordsStream does not delay when records are present (pollingActive)`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // Always returns records: the idle-wait `records.length === 0` conjunct is + // false, so no timeout should ever be scheduled. A `true` mutant on that + // conjunct would delay between every (non-empty) poll and the consumer would + // stall on the (un-ticked) mock timer. + client.on(GetRecordsCommand).resolves({ + Records: [{ Data: "a" }], + NextShardIterator: "iter-next", + }); + + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 60_000, + }; + const stream = await awsKinesisGetRecordsStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 3) break; + } + deepStrictEqual(output.length, 3); +}); + +test(`${variant}: awsKinesisGetRecordsStream pollingDelay 0 schedules no timer`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // pollingActive with empty records but pollingDelay === 0: the `pollingDelay > 0` + // guard is false so no idle wait runs. A `> 0` -> `true` or `>= 0` mutant would + // schedule a timeout(0) that the un-ticked mock timer never resolves, stalling + // the consumer (which would otherwise reach the 2nd record immediately). + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [], NextShardIterator: "iter2" }) + .resolves({ Records: [{ Data: "a" }], NextShardIterator: "iter3" }); + + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 0, + }; + const stream = await awsKinesisGetRecordsStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 1) break; + } + deepStrictEqual(output, [{ Data: "a" }]); +}); + +test(`${variant}: awsKinesisGetRecordsStream non-polling empty page ends without an idle wait`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // Not polling: an empty page must end the stream WITHOUT entering the idle + // wait. The `&&` -> `||` mutant makes `pollingActive || records.length === 0` + // true on the empty page, scheduling a (default 1000ms) timeout the un-ticked + // mock timer never resolves -> the stream would hang instead of completing. + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [{ Data: "a" }], NextShardIterator: "iter2" }) + .resolves({ Records: [], NextShardIterator: "iter3" }); + + const stream = await awsKinesisGetRecordsStream({ ShardIterator: "iter1" }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ Data: "a" }]); +}); + +// *** record byte accounting *** // +test(`${variant}: awsKinesisPutRecordsStream accepts a record with no Data field`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // No Data property: the `record.Data != null` guard must skip the Data byte + // count. An `if (true)` mutant would call _byteLength(undefined) and throw. + const input = [{ PartitionKey: "p" }]; + await pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 1); +}); + +test(`${variant}: awsKinesisPutRecordsStream counts ExplicitHashKey as the deciding byte`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // Data is EXACTLY 1MiB (allowed on its own, no PartitionKey). The 1-byte + // ExplicitHashKey is the ONLY thing tipping the record over 1MiB, so the + // `if (record.ExplicitHashKey != null)` branch (and its `+=`) must run. A + // `false`/`{}` mutant skips it and the record would be wrongly accepted. + const data = "x".repeat(1024 * 1024); + const input = [{ Data: data, ExplicitHashKey: "h" }]; + await rejects( + () => + pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]), + { message: "awsKinesisPutRecords record exceeds 1MiB limit" }, + ); +}); + +test(`${variant}: awsKinesisPutRecordsStream measures non-string Data by byteLength`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // A Uint8Array exactly at the 1MiB limit must be accepted via .byteLength. + // The `typeof value === "string"` branch must be FALSE here; a `true` mutant + // would TextEncoder.encode() the typed array (encoding its iterable/UTF-8 view + // rather than measuring byteLength) and the boundary check would shift. + const exact = new Uint8Array(1024 * 1024); + await pipeline([ + createReadableStream([{ Data: exact }]), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 1); + + const over = new Uint8Array(1024 * 1024 + 1); + await rejects( + () => + pipeline([ + createReadableStream([{ Data: over }]), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]), + { message: "awsKinesisPutRecords record exceeds 1MiB limit" }, + ); +}); + +test(`${variant}: awsKinesisPutRecordsStream oversize error cause carries bytes and limit`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + const tooBig = "x".repeat(1024 * 1024 + 1); + await rejects( + () => + pipeline([ + createReadableStream([{ Data: tooBig, PartitionKey: "k" }]), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]), + (error) => { + deepStrictEqual( + error.message, + "awsKinesisPutRecords record exceeds 1MiB limit", + ); + // `{}` mutant on the cause object would drop these fields. bytes counts + // Data (1MiB+1) plus the 1-byte PartitionKey. + deepStrictEqual(error.cause.limit, 1024 * 1024); + deepStrictEqual(error.cause.bytes, 1024 * 1024 + 2); + return true; + }, + ); +}); + +// *** success short-circuit: a stray ErrorCode with FailedRecordCount 0 must not retry *** // +test(`${variant}: awsKinesisPutRecordsStream does not retry when FailedRecordCount is 0`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // FailedRecordCount 0 but a stray ErrorCode entry: the `if (!FailedRecordCount)` + // early return must fire so we do NOT loop into the filter-and-retry path. A + // `false` mutant (or removed return block) would see the ErrorCode and retry. + client.on(PutRecordsCommand).resolves({ + FailedRecordCount: 0, + Records: [{ ErrorCode: "ShouldBeIgnored" }], + }); + + await pipeline([ + createReadableStream([{ Data: "a", PartitionKey: "a" }]), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 1); +}); + +// *** results indexing tolerates a short Records array (optional chaining) *** // +test(`${variant}: awsKinesisPutRecordsStream tolerates a results array shorter than records`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // Two records sent, but the response Records array has a single entry. The + // failed-records filter indexes results[1] which is undefined; the + // `results[index]?.ErrorCode` optional chain must guard it. A non-optional + // `results[index].ErrorCode` mutant throws a TypeError on index 1. + client + .on(PutRecordsCommand) + .resolvesOnce({ + FailedRecordCount: 1, + Records: [{ ErrorCode: "ProvisionedThroughputExceededException" }], + }) + .resolves({ FailedRecordCount: 0 }); + + await pipeline([ + createReadableStream([ + { Data: "a", PartitionKey: "a" }, + { Data: "b", PartitionKey: "b" }, + ]), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + + const calls = client.commandCalls(PutRecordsCommand); + deepStrictEqual(calls.length, 2); + // Only the first record (the one with an ErrorCode) is retried. + deepStrictEqual(calls[1].args[0].input.Records, [ + { Data: "a", PartitionKey: "a" }, + ]); +}); + +// *** retryCount must increment so retryMaxCount is eventually reached *** // +test(`${variant}: awsKinesisPutRecordsStream stops retrying after retryMaxCount`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // Persistent failure with retryMaxCount 2: retryCount must climb 0->1->2 and + // throw after 3 attempts. A `retryCount--` mutant would drive it negative, + // never reach the cap, and retry forever (test timeout = killed). + client.on(PutRecordsCommand).resolves({ + FailedRecordCount: 1, + Records: [{ ErrorCode: "InternalFailure" }], + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ Data: "a", PartitionKey: "a" }]), + awsKinesisPutRecordsStream({ + StreamName: "test-stream", + retryMaxCount: 2, + }), + ]), + { message: "awsKinesisPutRecords has failed records" }, + ); + // 1 initial + 2 retries = 3 attempts. + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 3); +}); + +// *** aggregate byte cap is 5MiB - 64KiB (subtract 64*1024, not 64/1024) *** // +test(`${variant}: awsKinesisPutRecordsStream cap subtracts 64KiB (not 64/1024)`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // Real cap = 5*1024*1024 - 64*1024 = 5,177,344. A `64 / 1024` mutant makes the + // cap ~5,242,879.94 (essentially 5MiB). Five 1,048,001-byte records sum to + // 5,240,005 which is OVER the real cap (so the 5th forces a flush after 4) but + // UNDER the mutated cap (so all five would batch together). + const data = "x".repeat(1_048_000); // 1,048,000 Data + 1-byte PartitionKey + const input = Array.from({ length: 5 }, (_v, i) => ({ + Data: data, + PartitionKey: `${i % 10}`, + })); + await pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + + const calls = client.commandCalls(PutRecordsCommand); + deepStrictEqual(calls.length, 2); + deepStrictEqual(calls[0].args[0].input.Records.length, 4); + deepStrictEqual(calls[1].args[0].input.Records.length, 1); +}); + +// *** byte-cap boundary uses strict `>` (equal-to-cap stays in the batch) *** // +test(`${variant}: awsKinesisPutRecordsStream keeps a batch summing exactly to the cap`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + client.on(PutRecordsCommand).resolves({ FailedRecordCount: 0 }); + + // 65,536-byte records (65,535 Data + 1-byte PartitionKey). 79 of them sum to + // exactly the cap (79 * 65,536 = 5,177,344). With strict `>` the 79th stays in + // the batch (flush happens only when the 80th would exceed the cap) -> first + // flush carries 79. A `>=` mutant flushes one record early -> 78. + const data = "x".repeat(65_535); + const input = Array.from({ length: 80 }, (_v, i) => ({ + Data: data, + PartitionKey: `${i % 10}`, + })); + await pipeline([ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]); + + const calls = client.commandCalls(PutRecordsCommand); + deepStrictEqual(calls.length, 2); + deepStrictEqual(calls[0].args[0].input.Records.length, 79); + deepStrictEqual(calls[1].args[0].input.Records.length, 1); +}); + +// *** PutRecords response.Records undefined fallback (`?? []`) *** // +test(`${variant}: awsKinesisPutRecordsStream falls back to empty array when response.Records is undefined`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // FailedRecordCount is truthy but Records is absent (undefined) -> `?? []` must + // produce an empty array so the failed.length check returns [] and returns early. + // A missing `?? []` would call .filter() on undefined and throw a TypeError. + client + .on(PutRecordsCommand) + .resolvesOnce({ + FailedRecordCount: 1, + // Records intentionally absent + }) + .resolves({ FailedRecordCount: 0 }); + + const input = [{ Data: "a", PartitionKey: "a" }]; + const stream = [ + createReadableStream(input), + awsKinesisPutRecordsStream({ StreamName: "test-stream" }), + ]; + await pipeline(stream); + + // The first response had no ErrorCode entries (empty array after fallback), + // so no retry. Only one PutRecords call. + deepStrictEqual(client.commandCalls(PutRecordsCommand).length, 1); +}); + +// *** putRecords backoff is abortable (signal forwarded to the timeout) *** // +test(`${variant}: awsKinesisPutRecordsStream aborts during retry backoff`, async (_t) => { + const client = mockClient(KinesisClient); + awsKinesisSetClient(client); + // Persistent partial failure so the stream enters the (real-timer) backoff. + client.on(PutRecordsCommand).resolves({ + FailedRecordCount: 1, + Records: [{ ErrorCode: "ProvisionedThroughputExceededException" }], + }); + + let sends = 0; + client.on(PutRecordsCommand).callsFake(() => { + sends++; + return Promise.resolve({ + FailedRecordCount: 1, + Records: [{ ErrorCode: "ProvisionedThroughputExceededException" }], + }); + }); + + const controller = new AbortController(); + const consuming = pipeline([ + createReadableStream([{ Data: "a", PartitionKey: "a" }]), + awsKinesisPutRecordsStream( + { StreamName: "test-stream" }, + { signal: controller.signal }, + ), + ]); + + // Let the first send fail and enter the backoff timer, then abort. + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); + + // The backoff timeout was given `{ signal }`, so the abort rejects the pending + // backoff immediately and NO further PutRecords is issued. A `{}` mutant + // (dropping the signal) leaves the backoff running so it wakes and retries + // repeatedly during this window -> far more than one send. + await new Promise((resolve) => setTimeout(resolve, 300)); + deepStrictEqual(sends, 1); +}); + +// The default export must expose exactly the public surface (setClient plus the +// two stream factories). An `ObjectLiteral` mutant collapsing it to `{}` would +// drop every key. +test(`${variant}: kinesis default export exposes all stream functions`, (_t) => { + deepStrictEqual(Object.keys(kinesisDefault).sort(), [ + "getRecordsStream", + "putRecordsStream", + "setClient", + ]); + deepStrictEqual(kinesisDefault.setClient, awsKinesisSetClient); + deepStrictEqual(kinesisDefault.getRecordsStream, awsKinesisGetRecordsStream); + deepStrictEqual(kinesisDefault.putRecordsStream, awsKinesisPutRecordsStream); +}); diff --git a/packages/aws/lambda.js b/packages/aws/lambda.js index 45bc198..c89e256 100644 --- a/packages/aws/lambda.js +++ b/packages/aws/lambda.js @@ -20,13 +20,18 @@ export const awsLambdaReadableStream = (lambdaOptions, streamOptions = {}) => { }; export const awsLambdaResponseStream = awsLambdaReadableStream; +// Fail-fast across the array form: a send() rejection or an InvokeComplete +// ErrorCode terminates the generator and later invocations do not run. The +// thrown Error's cause carries the offending FunctionName/index so consumers +// can identify which invocation failed. async function* awsLambdaGenerator(lambdaOptions, streamOptions = {}) { if (!Array.isArray(lambdaOptions)) { lambdaOptions = [lambdaOptions]; } - for (const options of lambdaOptions) { - const response = await defaultClient.send( - new InvokeWithResponseStreamCommand(options), + for (let index = 0; index < lambdaOptions.length; index++) { + const { client, ...invokeOptions } = lambdaOptions[index]; + const response = await (client ?? defaultClient).send( + new InvokeWithResponseStreamCommand(invokeOptions), { abortSignal: streamOptions.signal }, ); for await (const chunk of response.EventStream) { @@ -34,7 +39,11 @@ async function* awsLambdaGenerator(lambdaOptions, streamOptions = {}) { yield chunk.PayloadChunk.Payload; } else if (chunk?.InvokeComplete?.ErrorCode) { throw new Error(chunk.InvokeComplete.ErrorCode, { - cause: chunk.InvokeComplete.ErrorDetails, + cause: { + FunctionName: invokeOptions.FunctionName, + index, + ErrorDetails: chunk.InvokeComplete.ErrorDetails, + }, }); } } diff --git a/packages/aws/lambda.test.js b/packages/aws/lambda.test.js index 5323cbe..d492d10 100644 --- a/packages/aws/lambda.test.js +++ b/packages/aws/lambda.test.js @@ -73,7 +73,8 @@ if (variant === "node") { strictEqual(true, false); } catch (e) { strictEqual(e.message, "ErrorCode"); - strictEqual(e.cause, "ErrorDetails"); + strictEqual(e.cause.ErrorDetails, "ErrorDetails"); + strictEqual(e.cause.index, 0); } }); @@ -147,6 +148,95 @@ if (variant === "node") { deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); }); + test(`${variant}: awsLambdaReadableStream should use per-call client option`, async (_t) => { + // default client must NOT be used when options.client is supplied + const defaultClientMock = mockClient(LambdaClient); + awsLambdaSetClient(defaultClientMock); + defaultClientMock + .on(InvokeWithResponseStreamCommand) + .rejects(new Error("default client should not be called")); + + const perCallClient = mockClient(LambdaClient); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + perCallClient.on(InvokeWithResponseStreamCommand).resolves({ + EventStream: createReadableStream([ + { PayloadChunk: { Payload: encoder.encode("z") } }, + ]), + }); + + let result = ""; + for await (const chunk of await awsLambdaReadableStream({ + FunctionName: "f", + client: perCallClient, + })) { + result += decoder.decode(chunk); + } + + deepStrictEqual(result, "z"); + deepStrictEqual( + perCallClient.commandCalls(InvokeWithResponseStreamCommand).length, + 1, + ); + deepStrictEqual( + defaultClientMock.commandCalls(InvokeWithResponseStreamCommand).length, + 0, + ); + }); + + test(`${variant}: awsLambdaReadableStream should not send client field as invoke input`, async (_t) => { + const perCallClient = mockClient(LambdaClient); + const encoder = new TextEncoder(); + perCallClient.on(InvokeWithResponseStreamCommand).resolves({ + EventStream: createReadableStream([ + { PayloadChunk: { Payload: encoder.encode("ok") } }, + ]), + }); + + for await (const _chunk of await awsLambdaReadableStream({ + FunctionName: "f", + client: perCallClient, + })) { + // consume + } + + const calls = perCallClient.commandCalls(InvokeWithResponseStreamCommand); + deepStrictEqual(calls[0].args[0].input.client, undefined); + deepStrictEqual(calls[0].args[0].input.FunctionName, "f"); + }); + + test(`${variant}: awsLambdaReadableStream should include FunctionName in error cause`, async (_t) => { + const client = mockClient(LambdaClient); + awsLambdaSetClient(client); + + client + .on(InvokeWithResponseStreamCommand, { FunctionName: "fn1" }) + .resolves({ + EventStream: createReadableStream([ + { + InvokeComplete: { + ErrorCode: "Unhandled", + ErrorDetails: "boom", + }, + }, + ]), + }); + + try { + for await (const _chunk of await awsLambdaReadableStream([ + { FunctionName: "fn1" }, + ])) { + // consume + } + strictEqual(true, false); + } catch (e) { + strictEqual(e.message, "Unhandled"); + strictEqual(e.cause.FunctionName, "fn1"); + strictEqual(e.cause.index, 0); + strictEqual(e.cause.ErrorDetails, "boom"); + } + }); + test(`${variant}: default export should include all stream functions`, (_t) => { deepStrictEqual(Object.keys(lambdaDefault).sort(), [ "readableStream", @@ -154,4 +244,127 @@ if (variant === "node") { "setClient", ]); }); + + // A non-payload, non-complete event must be silently skipped: neither yielded + // nor treated as an error. Pins the `chunk?.PayloadChunk?.Payload` / + // `chunk?.InvokeComplete?.ErrorCode` guards and the `else if (...)` condition + // (an `else if (true)` mutant would throw on the empty frame). + test(`${variant}: awsLambdaReadableStream skips frames with neither PayloadChunk nor InvokeComplete`, async (_t) => { + const client = mockClient(LambdaClient); + awsLambdaSetClient(client); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + // A null/undefined frame must be tolerated by the leading `chunk?.` guards: + // dropping the optional chain (`chunk.PayloadChunk` / `chunk.InvokeComplete`) + // would throw a TypeError on these frames. + client.on(InvokeWithResponseStreamCommand).resolves({ + EventStream: createReadableStream([ + null, + undefined, + {}, + { SomethingElse: { foo: "bar" } }, + { PayloadChunk: { Payload: encoder.encode("ok") } }, + { InvokeComplete: {} }, + ]), + }); + + let result = ""; + for await (const chunk of await awsLambdaReadableStream({ + FunctionName: "f", + })) { + result += decoder.decode(chunk); + } + deepStrictEqual(result, "ok"); + }); + + // An InvokeComplete WITHOUT an ErrorCode must NOT throw (success completion). + // Kills the `else if (true)` mutant which would throw on every complete. + test(`${variant}: awsLambdaReadableStream treats InvokeComplete without ErrorCode as success`, async (_t) => { + const client = mockClient(LambdaClient); + awsLambdaSetClient(client); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + client.on(InvokeWithResponseStreamCommand).resolves({ + EventStream: createReadableStream([ + { PayloadChunk: { Payload: encoder.encode("done") } }, + { InvokeComplete: { LogResult: "..." } }, + ]), + }); + + let result = ""; + for await (const chunk of await awsLambdaReadableStream({ + FunctionName: "f", + })) { + result += decoder.decode(chunk); + } + deepStrictEqual(result, "done"); + }); + + // setClient must STORE the passed client as the module default. mockClient + // patches the SDK prototype (so instance identity is invisible to it); use a + // plain stub whose own send() proves the stored reference is actually used. A + // `setClient(){}` mutant leaves the original default in place and the stub's + // send is never called. + test(`${variant}: awsLambdaSetClient stores the passed client as the default`, async (_t) => { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + let calls = 0; + const stub = { + send: async () => { + calls++; + return { + EventStream: createReadableStream([ + { PayloadChunk: { Payload: encoder.encode("stub") } }, + ]), + }; + }, + }; + awsLambdaSetClient(stub); + + let result = ""; + for await (const chunk of await awsLambdaReadableStream({ + FunctionName: "f", + })) { + result += decoder.decode(chunk); + } + deepStrictEqual(result, "stub"); + deepStrictEqual(calls, 1); + }); + + // setClient swaps the module default used when no per-call client is given. + test(`${variant}: awsLambdaSetClient routes to the newly set default client`, async (_t) => { + const first = mockClient(LambdaClient); + awsLambdaSetClient(first); + first + .on(InvokeWithResponseStreamCommand) + .rejects(new Error("stale default client should not be used")); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const second = mockClient(LambdaClient); + second.on(InvokeWithResponseStreamCommand).resolves({ + EventStream: createReadableStream([ + { PayloadChunk: { Payload: encoder.encode("new") } }, + ]), + }); + awsLambdaSetClient(second); + + let result = ""; + for await (const chunk of await awsLambdaReadableStream({ + FunctionName: "f", + })) { + result += decoder.decode(chunk); + } + deepStrictEqual(result, "new"); + deepStrictEqual( + second.commandCalls(InvokeWithResponseStreamCommand).length, + 1, + ); + deepStrictEqual( + first.commandCalls(InvokeWithResponseStreamCommand).length, + 0, + ); + }); } diff --git a/packages/aws/msk-iam.d.ts b/packages/aws/msk-iam.d.ts new file mode 100644 index 0000000..8685084 --- /dev/null +++ b/packages/aws/msk-iam.d.ts @@ -0,0 +1,13 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT + +export interface AwsMskIamMechanism { + mechanism: "oauthbearer"; + oauthBearerProvider: () => Promise<{ value: string; expiryTime?: number }>; +} + +export function awsMskIamMechanism(options: { + region: string; + awsDebugCreds?: boolean; + ttl?: number; +}): AwsMskIamMechanism; diff --git a/packages/aws/msk-iam.js b/packages/aws/msk-iam.js new file mode 100644 index 0000000..a84f3b3 --- /dev/null +++ b/packages/aws/msk-iam.js @@ -0,0 +1,27 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { generateAuthToken } from "aws-msk-iam-sasl-signer-js"; + +// SECURITY: `awsDebugCreds: true` is forwarded verbatim to +// aws-msk-iam-sasl-signer-js's generateAuthToken, which then LOGS the resolved +// AWS access key id / credential details. This leaks credential material into +// application logs. Never enable awsDebugCreds in production; treat it as a +// local-debugging-only flag. +export const awsMskIamMechanism = ({ region, awsDebugCreds, ttl } = {}) => { + if (!region) { + throw new TypeError("awsMskIamMechanism: region required"); + } + return { + mechanism: "oauthbearer", + oauthBearerProvider: async () => { + const { token, expiryTime } = await generateAuthToken({ + region, + awsDebugCreds, + ttl, + }); + return { value: token, expiryTime }; + }, + }; +}; + +export default { mechanism: awsMskIamMechanism }; diff --git a/packages/aws/msk-iam.test.js b/packages/aws/msk-iam.test.js new file mode 100644 index 0000000..1b4909c --- /dev/null +++ b/packages/aws/msk-iam.test.js @@ -0,0 +1,93 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import test from "node:test"; +import mskDefault, { awsMskIamMechanism } from "@datastream/aws/msk-iam"; + +let variant = "unknown"; +for (const execArgv of process.execArgv) { + const flag = "--conditions="; + if (execArgv.includes(flag)) { + variant = execArgv.replace(flag, ""); + } +} + +test(`${variant}: awsMskIamMechanism returns kafkajs OAUTHBEARER config`, () => { + const mech = awsMskIamMechanism({ region: "us-east-1" }); + strictEqual(mech.mechanism, "oauthbearer"); + ok(typeof mech.oauthBearerProvider === "function"); +}); + +test(`${variant}: awsMskIamMechanism rejects missing region`, () => { + try { + awsMskIamMechanism({}); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("region")); + } +}); + +test(`${variant}: msk-iam default export exposes the mechanism factory`, () => { + deepStrictEqual(Object.keys(mskDefault), ["mechanism"]); + strictEqual(mskDefault.mechanism, awsMskIamMechanism); +}); + +// generateAuthToken presigns a Kafka URL locally from static credentials (no +// network), so we can exercise oauthBearerProvider end-to-end by supplying +// throwaway credentials via the environment for the duration of the call. +test(`${variant}: oauthBearerProvider returns a token and expiry`, async () => { + const prev = { + id: process.env.AWS_ACCESS_KEY_ID, + secret: process.env.AWS_SECRET_ACCESS_KEY, + region: process.env.AWS_REGION, + }; + process.env.AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"; + process.env.AWS_SECRET_ACCESS_KEY = + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + process.env.AWS_REGION = "us-east-1"; + try { + const mech = awsMskIamMechanism({ region: "us-east-1" }); + const result = await mech.oauthBearerProvider(); + // The provider must surface generateAuthToken's { token, expiryTime } as + // { value, expiryTime } — a `{}` body (or a `{}` return) drops these. + ok(typeof result.value === "string"); + ok(result.value.length > 0); + ok(typeof result.expiryTime === "number"); + ok(result.expiryTime > 0); + } finally { + if (prev.id === undefined) delete process.env.AWS_ACCESS_KEY_ID; + else process.env.AWS_ACCESS_KEY_ID = prev.id; + if (prev.secret === undefined) delete process.env.AWS_SECRET_ACCESS_KEY; + else process.env.AWS_SECRET_ACCESS_KEY = prev.secret; + if (prev.region === undefined) delete process.env.AWS_REGION; + else process.env.AWS_REGION = prev.region; + } +}); + +// The `generateAuthToken({ region, awsDebugCreds, ttl })` argument object must +// forward the resolver's region; a `{}` mutant drops region and the signer +// rejects with a region-required error. We assert the provider rejects when no +// region can be resolved from args or environment. +test(`${variant}: oauthBearerProvider forwards region to the signer`, async () => { + const prevRegion = process.env.AWS_REGION; + const prevDefault = process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + try { + // Build the mechanism with a region so awsMskIamMechanism itself passes, + // then strip the env so the ONLY region the signer can see is the one + // forwarded inside the generateAuthToken({ region }) object literal. + const mech = awsMskIamMechanism({ region: "us-east-1" }); + process.env.AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"; + process.env.AWS_SECRET_ACCESS_KEY = + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + const result = await mech.oauthBearerProvider(); + ok(typeof result.value === "string"); + ok(result.value.length > 0); + } finally { + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + if (prevRegion !== undefined) process.env.AWS_REGION = prevRegion; + if (prevDefault !== undefined) process.env.AWS_DEFAULT_REGION = prevDefault; + } +}); diff --git a/packages/aws/package.json b/packages/aws/package.json index 79058ee..771a00a 100644 --- a/packages/aws/package.json +++ b/packages/aws/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/aws", - "version": "0.5.0", + "version": "0.6.1", "description": "AWS service streaming integrations for CloudWatch Logs, DynamoDB, DynamoDB Streams, Kinesis, Lambda, S3, SNS, and SQS", "type": "module", "engines": { @@ -120,18 +120,44 @@ "types": "./sqs.d.ts", "default": "./sqs.web.mjs" } + }, + "./msk-iam": { + "node": { + "import": { + "types": "./msk-iam.d.ts", + "default": "./msk-iam.node.mjs" + } + }, + "import": { + "types": "./msk-iam.d.ts", + "default": "./msk-iam.web.mjs" + } + }, + "./glue-schema-registry": { + "node": { + "import": { + "types": "./glue-schema-registry.d.ts", + "default": "./glue-schema-registry.node.mjs" + } + }, + "import": { + "types": "./glue-schema-registry.d.ts", + "default": "./glue-schema-registry.web.mjs" + } } }, "types": "index.d.ts", "files": [ "*.mjs", "*.map", - "*.d.ts" + "*.d.ts", + "client.js" ], "scripts": { "test": "npm run test:unit", "test:unit": "node --test --conditions=node", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -161,19 +187,20 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" }, "peerDependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.1065.0", - "@aws-sdk/client-dynamodb": "^3.1065.0", - "@aws-sdk/client-dynamodb-streams": "^3.1065.0", - "@aws-sdk/client-kinesis": "^3.1065.0", - "@aws-sdk/client-lambda": "^3.1065.0", - "@aws-sdk/client-s3": "^3.1065.0", - "@aws-sdk/client-sns": "^3.1065.0", - "@aws-sdk/client-sqs": "^3.1065.0", - "@aws-sdk/client-ssm": "^3.1065.0", - "@aws-sdk/lib-storage": "^3.1065.0" + "@aws-sdk/client-cloudwatch-logs": "^3.0.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/client-dynamodb-streams": "^3.0.0", + "@aws-sdk/client-glue": "^3.0.0", + "@aws-sdk/client-kinesis": "^3.0.0", + "@aws-sdk/client-lambda": "^3.0.0", + "@aws-sdk/client-s3": "^3.0.0", + "@aws-sdk/client-sns": "^3.0.0", + "@aws-sdk/client-sqs": "^3.0.0", + "@aws-sdk/lib-storage": "^3.0.0", + "aws-msk-iam-sasl-signer-js": "^1.0.0" }, "peerDependenciesMeta": { "@aws-sdk/client-cloudwatch-logs": { @@ -185,6 +212,9 @@ "@aws-sdk/client-dynamodb-streams": { "optional": true }, + "@aws-sdk/client-glue": { + "optional": true + }, "@aws-sdk/client-kinesis": { "optional": true }, @@ -200,24 +230,25 @@ "@aws-sdk/client-sqs": { "optional": true }, - "@aws-sdk/client-ssm": { + "@aws-sdk/lib-storage": { "optional": true }, - "@aws-sdk/lib-storage": { + "aws-msk-iam-sasl-signer-js": { "optional": true } }, "devDependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.1065.0", - "@aws-sdk/client-dynamodb": "^3.1065.0", - "@aws-sdk/client-dynamodb-streams": "^3.1065.0", - "@aws-sdk/client-kinesis": "^3.1065.0", - "@aws-sdk/client-lambda": "^3.1065.0", - "@aws-sdk/client-s3": "^3.1065.0", - "@aws-sdk/client-sns": "^3.1065.0", - "@aws-sdk/client-sqs": "^3.1065.0", - "@aws-sdk/client-ssm": "^3.1065.0", - "@aws-sdk/lib-storage": "^3.1065.0", + "@aws-sdk/client-cloudwatch-logs": "^3.0.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/client-dynamodb-streams": "^3.0.0", + "@aws-sdk/client-glue": "^3.0.0", + "@aws-sdk/client-kinesis": "^3.0.0", + "@aws-sdk/client-lambda": "^3.0.0", + "@aws-sdk/client-s3": "^3.0.0", + "@aws-sdk/client-sns": "^3.0.0", + "@aws-sdk/client-sqs": "^3.0.0", + "@aws-sdk/lib-storage": "^3.0.0", + "aws-msk-iam-sasl-signer-js": "^1.0.0", "aws-sdk-client-mock": "^4.0.0" } } diff --git a/packages/aws/s3.js b/packages/aws/s3.js index b74472d..507bead 100644 --- a/packages/aws/s3.js +++ b/packages/aws/s3.js @@ -24,12 +24,41 @@ export const awsS3GetObjectStream = async (options, streamOptions = {}) => { if (!Body) { throw new Error("S3.GetObject not found", { cause: params }); } - return createReadableStream(Body, streamOptions); + const stream = createReadableStream(Body, streamOptions); + // Tie the SDK Body (live socket-backed readable) lifecycle to the returned + // wrapper: if the consumer errors/aborts, tear down Body so the underlying + // HTTP connection is not leaked. + const teardownBody = () => { + // The node SDK Body is a Readable (destroy). The try/catch swallows teardown + // errors so releasing the socket cannot re-throw on an already-failed Body + // (and tolerates a Body that does not expose destroy()). + try { + Body.destroy(); + } catch {} + }; + // Node build: createReadableStream returns a node Readable; clean up on its + // 'error' event (without an error argument, so releasing the socket does not + // re-emit an unhandled 'error' on the already-failed Body). + stream.on("error", teardownBody); + // Any build given an abort signal also wires teardown to the abort signal so + // socket teardown on consumer abort is consistent across builds. + const { signal } = streamOptions; + if (signal) { + if (signal.aborted) { + teardownBody(); + } else { + signal.addEventListener("abort", teardownBody, { once: true }); + } + } + return stream; }; export const awsS3PutObjectStream = (options, streamOptions = {}) => { - const { onProgress, client, tags, ...params } = options; + const { onProgress, client, tags, partSize, queueSize, ...params } = options; const stream = createPassThroughStream(() => {}, streamOptions); + // lib-storage defaults to a 5 MiB partSize and a 10,000-part ceiling + // (~50 GiB max object). Expose partSize/queueSize so callers can raise the + // ceiling for very large streamed objects. const upload = new Upload({ client: client ?? defaultClient, params: { @@ -37,6 +66,8 @@ export const awsS3PutObjectStream = (options, streamOptions = {}) => { Body: stream, }, tags, + partSize, + queueSize, }); if (onProgress) { stream.on("httpUploadProgress", onProgress); @@ -62,26 +93,54 @@ export const awsS3ChecksumStream = ( if (!algorithm) throw new Error(`Unsupported ChecksumAlgorithm: ${ChecksumAlgorithm}`); let checksums = []; - let bytes = new Uint8Array(0); + const pending = []; + let pendingLen = 0; + const takePart = () => { + const part = new Uint8Array(partSize); + let filled = 0; + while (filled < partSize) { + const head = pending[0]; + // Copy as much of the head chunk as the part still needs. `rest` is + // whatever is left of the head afterwards: drop the head once it is fully + // consumed, otherwise keep the remainder at the front for the next part. + const take = Math.min(head.byteLength, partSize - filled); + part.set(head.subarray(0, take), filled); + filled += take; + const rest = head.subarray(take); + if (rest.byteLength === 0) { + pending.shift(); + } else { + pending[0] = rest; + } + } + pendingLen -= partSize; + return part; + }; const passThrough = async (chunk) => { if (typeof chunk === "string") { chunk = new TextEncoder().encode(chunk); + } else { + // Normalize ArrayBuffer/Buffer/Uint8Array to a plain Uint8Array so + // takePart's subarray/set views are always valid. + chunk = new Uint8Array(chunk); } - while (bytes.byteLength + chunk.byteLength > partSize) { - chunk = _concatBuffers([bytes, chunk]); - const prefixChunk = chunk.slice(0, partSize); - - const checksum = await crypto.subtle.digest(algorithm, prefixChunk); + pending.push(chunk); + pendingLen += chunk.byteLength; + // Digest every whole part the buffered bytes can supply; any trailing + // partial part (< partSize) stays buffered for the next chunk or the flush. + const wholeParts = Math.floor(pendingLen / partSize); + for (let part = 0; part < wholeParts; part++) { + const checksum = await crypto.subtle.digest(algorithm, takePart()); checksums.push(checksum); - chunk = chunk.slice(prefixChunk.byteLength); - - bytes = new Uint8Array(0); } - bytes = _concatBuffers([bytes, chunk]); }; const flush = async () => { - if (bytes.byteLength) { - const checksum = await crypto.subtle.digest(algorithm, bytes); + if (pendingLen > 0) { + // Remainder is < partSize: a single concat of the leftover chunks. + const checksum = await crypto.subtle.digest( + algorithm, + _concatBuffers(pending), + ); checksums.push(checksum); } }; @@ -95,10 +154,11 @@ export const awsS3ChecksumStream = ( _concatBuffers(checksums), ); checksum = `${_arrayBufferToBase64(checksum)}-${checksums.length}`; - } else if (checksums.length === 1) { - checksum = _arrayBufferToBase64(checksums[0]); } else { - checksum = ""; + // Single part -> its base64. Empty input leaves checksums empty, and + // _arrayBufferToBase64(undefined) is the empty string, matching the + // "no data digested" result without a dedicated branch. + checksum = _arrayBufferToBase64(checksums[0]); } checksums = checksums.map(_arrayBufferToBase64); } @@ -129,13 +189,8 @@ const _concatBuffers = (buffers) => { return tmp.buffer; }; const _arrayBufferToBase64 = (buffer) => { - let binary = ""; const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); + return btoa(String.fromCharCode(...bytes)); }; export default { diff --git a/packages/aws/s3.test.js b/packages/aws/s3.test.js index 353a173..4b35764 100644 --- a/packages/aws/s3.test.js +++ b/packages/aws/s3.test.js @@ -1,4 +1,4 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import test from "node:test"; import { CreateMultipartUploadCommand, @@ -266,6 +266,38 @@ test(`${variant}: awsS3ChecksumStream should make multi-part checksum with small deepStrictEqual(result.s3.partSize, 50); }); +test(`${variant}: awsS3ChecksumStream should emit a trailing partial part below partSize`, async (_t) => { + // 130 bytes at partSize 50 -> two whole parts (100) plus a 30-byte remainder + // the flush digests: exactly three checksums. A non-exact multiple pins the + // floor() part-count so it cannot round/ceil into a phantom extra part. + const input = "x".repeat(130); + const options = { + ChecksumAlgorithm: "SHA256", + partSize: 50, + }; + + const stream = [createReadableStream(input), awsS3ChecksumStream(options)]; + const result = await pipeline(stream); + + deepStrictEqual(result.s3.checksums.length, 3); +}); + +test(`${variant}: awsS3ChecksumStream should assemble parts that span multiple chunks`, async (_t) => { + // Four 30-byte chunks (120 bytes) at partSize 50: each whole part is filled + // from more than one buffered chunk, exercising the partial-chunk carry-over + // (a part needs 50 = 30 + 20, leaving a 10-byte tail of the second chunk). + const input = Array.from({ length: 4 }, () => new Uint8Array(30).fill(7)); + const options = { + ChecksumAlgorithm: "SHA256", + partSize: 50, + }; + + const stream = [createReadableStream(input), awsS3ChecksumStream(options)]; + const result = await pipeline(stream); + + deepStrictEqual(result.s3.checksums.length, 3); +}); + test(`${variant}: awsS3ChecksumStream should make checksum with custom resultKey`, async (_t) => { const input = "x".repeat(16_384); const options = { @@ -295,6 +327,22 @@ test(`${variant}: awsS3ChecksumStream should handle Uint8Array input`, async (_t deepStrictEqual(result.s3.checksums.length, 2); }); +test(`${variant}: awsS3ChecksumStream should handle ArrayBuffer input`, async (_t) => { + // An ArrayBuffer is neither a string nor a Uint8Array, exercising the + // `new Uint8Array(chunk)` coercion branch. Wrapped in an array so object-mode + // delivery keeps it an ArrayBuffer rather than chunking it into Uint8Arrays. + const input = [new TextEncoder().encode("x".repeat(100)).buffer]; + const options = { + ChecksumAlgorithm: "SHA256", + partSize: 50, + }; + + const stream = [createReadableStream(input), awsS3ChecksumStream(options)]; + const result = await pipeline(stream); + + deepStrictEqual(result.s3.checksums.length, 2); +}); + test(`${variant}: awsS3GetObjectStream should use custom client option`, async (_t) => { const client = mockClient(S3Client); client @@ -359,6 +407,341 @@ test(`${variant}: awsS3GetObjectStream should pass abort signal to client.send`, deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); }); +// setClient must STORE the passed client; a plain stub (prototype-mock-proof) +// proves the stored reference is used. A `setClient(){}` mutant leaves the prior +// client in place and the stub's send would never run. +test(`${variant}: awsS3SetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { Body: createReadableStream("stub-data") }; + }, + }; + awsS3SetClient(stub); + + const stream = await awsS3GetObjectStream({ Bucket: "b", Key: "k" }); + const output = await streamToString(stream); + + deepStrictEqual(output, "stub-data"); + deepStrictEqual(calls, 1); +}); + +// The "not found" error carries the request params as its cause (kills the `{}` +// object-literal mutant on the error options). +test(`${variant}: awsS3GetObjectStream not-found error cause is the request params`, async (_t) => { + const client = mockClient(S3Client); + awsS3SetClient(client); + client.on(GetObjectCommand).resolves({}); + + const params = { Bucket: "bucket", Key: "missing.ext" }; + await rejects( + () => awsS3GetObjectStream({ ...params }), + (error) => { + deepStrictEqual(error.message, "S3.GetObject not found"); + deepStrictEqual(error.cause, params); + return true; + }, + ); +}); + +// On the node build the returned stream is a Readable; its 'error' event must +// tear down the SDK Body (a node Readable -> destroy()) so the socket is +// released. This pins the unconditional `stream.on("error", teardownBody)` +// wiring and the `Body.destroy()` call inside teardownBody. +test(`${variant}: awsS3GetObjectStream tears down the Body when the stream errors`, async (_t) => { + let destroyed = 0; + // An async-iterable Body that also exposes a destroy() spy (the node SDK Body + // is a Readable). + const body = { + async *[Symbol.asyncIterator]() { + yield "chunk"; + }, + destroy() { + destroyed++; + }, + }; + const stub = { send: async () => ({ Body: body }) }; + awsS3SetClient(stub); + + const stream = await awsS3GetObjectStream({ Bucket: "b", Key: "k" }); + // Emit an error on the returned node Readable -> the wired teardown runs. + await new Promise((resolve) => { + stream.on("error", () => resolve()); + stream.destroy(new Error("boom")); + }); + // Allow the 'error' listener (teardownBody) to run. + await new Promise((resolve) => setImmediate(resolve)); + + deepStrictEqual(destroyed, 1); +}); + +// An abort signal that fires AFTER the stream is created must tear down the Body +// via the addEventListener("abort", ...) wiring. Pins `if (signal)`, the else +// branch, the "abort" event-name literal, and proves teardown is not run eagerly. +test(`${variant}: awsS3GetObjectStream tears down the Body when a later abort fires`, async (_t) => { + let destroyed = 0; + const body = { + async *[Symbol.asyncIterator]() { + yield "chunk"; + }, + destroy() { + destroyed++; + }, + }; + const stub = { send: async () => ({ Body: body }) }; + awsS3SetClient(stub); + + const controller = new AbortController(); + const stream = await awsS3GetObjectStream( + { Bucket: "b", Key: "k" }, + { signal: controller.signal }, + ); + // Not aborted yet: teardown must NOT have run (kills `if (signal.aborted)` -> + // true, which would tear down eagerly). + deepStrictEqual(destroyed, 0); + + controller.abort(); + await new Promise((resolve) => setImmediate(resolve)); + // The "abort" listener fired teardownBody (the underlying Readable may also + // surface the abort via its 'error' event, so teardown can run more than once; + // it is idempotent). The key assertion is that it ran at all post-abort. + deepStrictEqual(destroyed >= 1, true); + + // Cleanup so the test does not leak the open stream. + stream.destroy(); +}); + +// Pin the EXACT addEventListener wiring for the late-abort path. A non-aborted +// signal whose addEventListener is recorded proves the source registers +// teardownBody under the "abort" event name with `{ once: true }`. The source's +// listener is identified by being the one whose invocation tears down the Body +// (the node Readable's own internal "abort" listener does not touch Body). This +// kills: dropping the else block (no registration), the "" event-name mutant, +// the `{}` options mutant, and the `{ once: false }` mutant. +test(`${variant}: awsS3GetObjectStream registers the abort listener with the exact name and options`, async (_t) => { + let destroyed = 0; + const body = { + async *[Symbol.asyncIterator]() { + yield "chunk"; + }, + destroy() { + destroyed++; + }, + }; + const stub = { send: async () => ({ Body: body }) }; + awsS3SetClient(stub); + + const recorded = []; + // A duck-typed (non-aborted) signal: createReadableStream and the source both + // register on it; we record every addEventListener call. + const signal = { + aborted: false, + addEventListener: (name, fn, options) => { + recorded.push({ name, fn, options }); + }, + removeEventListener: () => {}, + }; + + const stream = await awsS3GetObjectStream( + { Bucket: "b", Key: "k" }, + { signal }, + ); + // teardown must NOT have run eagerly (signal is not aborted). + deepStrictEqual(destroyed, 0); + + // Identify the source's teardown registration: it is the recorded entry whose + // listener, when invoked, tears down the Body. + let teardownEntry; + for (const entry of recorded) { + const before = destroyed; + entry.fn(); + if (destroyed > before) { + teardownEntry = entry; + break; + } + } + deepStrictEqual(teardownEntry?.name, "abort"); + deepStrictEqual(teardownEntry?.options, { once: true }); + + stream.destroy(); +}); + +// An already-aborted signal tears down the Body eagerly during creation. Pins +// `if (signal.aborted)` (a `false` mutant would skip the eager teardown). +test(`${variant}: awsS3GetObjectStream tears down the Body immediately for a pre-aborted signal`, async (_t) => { + let destroyed = 0; + const body = { + async *[Symbol.asyncIterator]() { + yield "chunk"; + }, + destroy() { + destroyed++; + }, + }; + const stub = { send: async () => ({ Body: body }) }; + awsS3SetClient(stub); + + const controller = new AbortController(); + controller.abort(); + const stream = await awsS3GetObjectStream( + { Bucket: "b", Key: "k" }, + { signal: controller.signal }, + ); + // Eager teardown happened synchronously during the call. + deepStrictEqual(destroyed, 1); + stream.destroy(); +}); + +// onProgress must be wired to the upload's 'httpUploadProgress' event. Pins the +// `if (onProgress)` branch and the "httpUploadProgress" event-name literal. +test(`${variant}: awsS3PutObjectStream forwards httpUploadProgress to onProgress`, async (_t) => { + const client = mockClient(S3Client); + const defaultClient = new S3Client(); + client.config ??= {}; + client.config.requestChecksumCalculation ??= + defaultClient.config.requestChecksumCalculation; + awsS3SetClient(client); + + client + .on(PutObjectCommand) + .rejects() + .on(CreateMultipartUploadCommand) + .resolves({ UploadId: "1" }) + .on(UploadPartCommand) + .resolves({ ETag: "1" }); + + let progressEvents = 0; + const options = { + Bucket: "bucket", + Key: "file.ext", + onProgress: () => { + progressEvents++; + }, + }; + + const stream = awsS3PutObjectStream(options); + // Emitting 'httpUploadProgress' must reach onProgress. A `""` event-name mutant + // or a skipped `if (onProgress)` would never invoke the callback. + stream.emit("httpUploadProgress", { loaded: 1, total: 1 }); + deepStrictEqual(progressEvents, 1); + + const input = "x".repeat(6 * 1024 * 1024); + const result = await pipeline([createReadableStream(input), stream]); + deepStrictEqual(result, {}); +}); + +// An unsupported ChecksumAlgorithm throws with an informative message (pins the +// `if (!algorithm)` branch and the non-empty error template). +test(`${variant}: awsS3ChecksumStream throws for an unsupported ChecksumAlgorithm`, async (_t) => { + let threw; + try { + awsS3ChecksumStream({ ChecksumAlgorithm: "NOPE" }); + } catch (error) { + threw = error; + } + deepStrictEqual(threw?.message, "Unsupported ChecksumAlgorithm: NOPE"); +}); + +// Empty input -> no parts digested -> checksum is the empty string and there are +// zero part checksums. Pins `if (bytes.byteLength)` (a `true` mutant would digest +// the empty buffer), the `else` empty-string branch and that string literal. +test(`${variant}: awsS3ChecksumStream returns empty checksum for empty input`, async (_t) => { + const stream = [createReadableStream([]), awsS3ChecksumStream({})]; + const result = await pipeline(stream); + deepStrictEqual(result.s3.checksum, ""); + deepStrictEqual(result.s3.checksums.length, 0); +}); + +// Single-part input -> exactly one part checksum and the result checksum is that +// single part's base64 (NOT the multi-part composite). Pins `checksums.length > 1` +// (false branch) and `checksums.length === 1`. +test(`${variant}: awsS3ChecksumStream single-part checksum equals the only part`, async (_t) => { + const input = "x".repeat(16_384); + const stream = [ + createReadableStream(input), + awsS3ChecksumStream({ ChecksumAlgorithm: "SHA256" }), + ]; + const result = await pipeline(stream); + deepStrictEqual(result.s3.checksums.length, 1); + // For a single part, checksum === the lone part checksum (no `-N` suffix). + deepStrictEqual(result.s3.checksum, result.s3.checksums[0]); +}); + +// Multi-part input -> composite checksum carries the `-` suffix (kills the +// empty-string-literal mutant on the composite template) and differs from any +// single part. Also re-calling result() returns the identical cached value +// (pins `if (!checksum)` memoization: a `true` mutant would recompute over the +// already-base64'd checksums and produce a different value). +test(`${variant}: awsS3ChecksumStream multi-part composite checksum is suffixed and cached`, async (_t) => { + const input = "x".repeat(100); + const checksumStream = awsS3ChecksumStream({ + ChecksumAlgorithm: "SHA256", + partSize: 50, + }); + await pipeline([createReadableStream(input), checksumStream]); + + const result1 = await checksumStream.result(); + deepStrictEqual(result1.value.checksums.length, 2); + // Pin the exact composite and per-part digests. A memoization mutant + // (`if (true)`) recomputes on the second call over the already-base64'd + // `checksums`, which the first call below would expose as a different value; + // even the first call's value must be the real composite digest (not the + // empty-input digest produced when the recompute path runs prematurely). + deepStrictEqual(result1.value.checksums, [ + "d88SBg1HGD6oxANF5ziefgXLB1PKs3Sl50+TKYFbTLU=", + "d88SBg1HGD6oxANF5ziefgXLB1PKs3Sl50+TKYFbTLU=", + ]); + deepStrictEqual( + result1.value.checksum, + "//kFcRsCAXRHbjZsPUmCkRBx+J1hJiSiqKAF/q7oMi0=-2", + ); + // Composite form: "-". + deepStrictEqual(result1.value.checksum.endsWith("-2"), true); + + const result2 = await checksumStream.result(); + deepStrictEqual(result1, result2); + deepStrictEqual(result2.value.checksum, result1.value.checksum); +}); + +// Input exactly equal to partSize stays a single part (the trailing partial is +// digested by the flush, not split off). +test(`${variant}: awsS3ChecksumStream keeps input exactly equal to partSize as one part`, async (_t) => { + const input = "x".repeat(50); + const stream = [ + createReadableStream(input), + awsS3ChecksumStream({ ChecksumAlgorithm: "SHA256", partSize: 50 }), + ]; + const result = await pipeline(stream); + deepStrictEqual(result.s3.checksums.length, 1); +}); + +// Streaming part boundaries across MULTIPLE chunks pin the per-chunk peel loop: +// each 60-byte chunk completes exactly one 50-byte part and carries a sub-part +// remainder forward, so the stream yields parts [50, 50, 20]. This pins +// `Math.floor(bytes.byteLength / partSize)` (dropping the floor over-peels the +// carried remainder into extra short parts -> 4 parts), the `part < wholeParts` +// loop bound (a `<=` over-iterates) and the `bytes.slice(partSize)` advance. +test(`${variant}: awsS3ChecksumStream peels whole parts across chunks and carries the remainder`, async (_t) => { + const stream = [ + createReadableStream(["x".repeat(60), "x".repeat(60)]), + awsS3ChecksumStream({ ChecksumAlgorithm: "SHA256", partSize: 50 }), + ]; + const result = await pipeline(stream); + deepStrictEqual(result.s3.checksums.length, 3); + // Two complete 50-byte parts (identical bytes -> identical digest) and a + // trailing 20-byte part. + deepStrictEqual(result.s3.checksums, [ + "d88SBg1HGD6oxANF5ziefgXLB1PKs3Sl50+TKYFbTLU=", + "d88SBg1HGD6oxANF5ziefgXLB1PKs3Sl50+TKYFbTLU=", + "1PwdtmVEZQfcUbDJOS3ZZJKRWBv+G0jiQbKwgDKztkc=", + ]); + deepStrictEqual( + result.s3.checksum, + "kE1tc6lRu6Azw5+k/yKQ/QDXDG236y62PebJpxEZQhQ=-3", + ); +}); + test(`${variant}: default export should include all stream functions`, (_t) => { deepStrictEqual(Object.keys(s3Default).sort(), [ "checksumStream", @@ -367,3 +750,28 @@ test(`${variant}: default export should include all stream functions`, (_t) => { "setClient", ]); }); + +// The teardownBody try/catch must swallow an error from Body.destroy() without +// re-throwing. Pins the empty `catch {}`. +test(`${variant}: awsS3GetObjectStream teardownBody swallows errors from destroy`, async (_t) => { + const body = { + async *[Symbol.asyncIterator]() { + yield "chunk"; + }, + destroy() { + throw new Error("destroy failed"); + }, + }; + const stub = { send: async () => ({ Body: body }) }; + awsS3SetClient(stub); + + const stream = await awsS3GetObjectStream({ Bucket: "b", Key: "k" }); + // Emit an error: teardownBody runs and both try/catch blocks execute. Neither + // throw may propagate (a rethrow mutant would cause an unhandled rejection here). + await new Promise((resolve) => { + stream.on("error", () => resolve()); + stream.destroy(new Error("boom")); + }); + await new Promise((resolve) => setImmediate(resolve)); + // If we reach here, the errors were swallowed correctly. +}); diff --git a/packages/aws/sns.js b/packages/aws/sns.js index 7ec9c0f..b8caedc 100644 --- a/packages/aws/sns.js +++ b/packages/aws/sns.js @@ -1,28 +1,94 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT import { PublishBatchCommand, SNSClient } from "@aws-sdk/client-sns"; -import { createWritableStream } from "@datastream/core"; +import { createWritableStream, timeout } from "@datastream/core"; import { awsClientDefaults } from "./client.js"; +// PublishBatch: <=10 entries, <=256KB aggregate payload. +const SNS_MAX_ENTRIES = 10; +const SNS_MAX_BATCH_BYTES = 256 * 1024; + +// Partial failures are overwhelmingly throttling-driven; a near-zero early +// delay (3^0 == 1ms) just hammers the throttled endpoint. Apply a floor so the +// first retries give capacity time to recover, while preserving the ~59sec cap. +const BACKOFF_FLOOR_MS = 50; +const BACKOFF_CAP_MS = 3 ** 10; +// streamOptions is always supplied by the exported stream function (defaulting +// to {}), so it is never nullish here. +const backoff = (retryCount, streamOptions) => + timeout( + Math.min(BACKOFF_CAP_MS, Math.max(BACKOFF_FLOOR_MS, 3 ** retryCount)), + { + signal: streamOptions.signal, + }, + ); + +const _textEncoder = new TextEncoder(); +const _byteLength = (chunk) => + _textEncoder.encode(JSON.stringify(chunk)).byteLength; + let client = new SNSClient(awsClientDefaults); export const awsSNSSetClient = (snsClient) => { client = snsClient; }; export const awsSNSPublishMessageStream = (options, streamOptions = {}) => { + const { retryMaxCount = 10, ...sendOptions } = options; let batch = []; - const send = () => { - options.PublishBatchRequestEntries = batch; + let batchBytes = 0; + const send = async () => { + if (!batch.length) { + return; + } + let entries = batch; batch = []; - return client.send(new PublishBatchCommand(options)); + batchBytes = 0; + let retryCount = 0; + while (true) { + const response = await client.send( + new PublishBatchCommand({ + ...sendOptions, + PublishBatchRequestEntries: entries, + }), + { abortSignal: streamOptions.signal }, + ); + const failed = response.Failed ?? []; + if (!failed.length) { + return; + } + const failedIds = new Set(failed.map((entry) => entry.Id)); + const failedEntries = entries.filter((entry) => failedIds.has(entry.Id)); + if (retryCount >= retryMaxCount) { + throw new Error("awsSNSPublishBatch has failed entries", { + cause: failed, + }); + } + await backoff(retryCount, streamOptions); + retryCount++; + entries = failedEntries; + } }; const write = async (chunk) => { - if (batch.length === 10) { + const chunkBytes = _byteLength(chunk); + // Surface an oversize single entry up front (mirroring Kinesis) instead of + // letting SNS reject the whole batch with a BatchRequestTooLong error. + if (chunkBytes > SNS_MAX_BATCH_BYTES) { + throw new Error("awsSNSPublishBatch entry exceeds 256KiB limit", { + // Reached only for an oversize entry (a non-nullish object), so + // reading chunk.Id directly is safe. + cause: { Id: chunk.Id, bytes: chunkBytes, limit: SNS_MAX_BATCH_BYTES }, + }); + } + if ( + batch.length === SNS_MAX_ENTRIES || + (batch.length && batchBytes + chunkBytes > SNS_MAX_BATCH_BYTES) + ) { await send(); } batch.push(chunk); + batchBytes += chunkBytes; }; - const final = () => (batch.length ? send() : undefined); + const final = () => send(); return createWritableStream(write, final, streamOptions); }; diff --git a/packages/aws/sns.test.js b/packages/aws/sns.test.js index c845380..baa211e 100644 --- a/packages/aws/sns.test.js +++ b/packages/aws/sns.test.js @@ -1,4 +1,4 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import test from "node:test"; import { PublishBatchCommand, SNSClient } from "@aws-sdk/client-sns"; import snsDefault, { @@ -63,6 +63,456 @@ test(`${variant}: awsSNSPublishMessageStream should handle empty input`, async ( deepStrictEqual(result, {}); }); +// *** Failed entries (data loss) *** // +test(`${variant}: awsSNSPublishMessageStream should retry failed entries`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + + const input = [ + { Id: "0", Message: "a" }, + { Id: "1", Message: "b" }, + ]; + const options = { + TopicArn: "arn:aws:sns:us-east-1:000000000000:test", + }; + + client + .on(PublishBatchCommand) + .resolvesOnce({ + Successful: [{ Id: "0" }], + Failed: [{ Id: "1", Code: "ThrottlingException", SenderFault: false }], + }) + .resolvesOnce({ + Successful: [{ Id: "1" }], + Failed: [], + }); + + const stream = [ + createReadableStream(input), + awsSNSPublishMessageStream(options), + ]; + await pipeline(stream); + + const calls = client.commandCalls(PublishBatchCommand); + deepStrictEqual(calls.length, 2); + deepStrictEqual(calls[1].args[0].input.PublishBatchRequestEntries, [ + { Id: "1", Message: "b" }, + ]); +}); + +test(`${variant}: awsSNSPublishMessageStream should throw when entries keep failing`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + + const input = [{ Id: "0", Message: "a" }]; + const options = { + TopicArn: "arn:aws:sns:us-east-1:000000000000:test", + retryMaxCount: 0, + }; + + client.on(PublishBatchCommand).resolves({ + Successful: [], + Failed: [{ Id: "0", Code: "InternalError", SenderFault: false }], + }); + + const stream = [ + createReadableStream(input), + awsSNSPublishMessageStream(options), + ]; + await rejects(() => pipeline(stream), { + message: "awsSNSPublishBatch has failed entries", + }); +}); + +test(`${variant}: awsSNSPublishMessageStream should flush before exceeding 256KB`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + // 5 messages of ~100KB each => 500KB total, well under count of 10 + // but over the 256KB byte limit, forcing multiple flushes. + const body = "x".repeat(100 * 1024); + const input = Array.from({ length: 5 }, (_v, i) => ({ + Id: `${i}`, + Message: body, + })); + const options = { + TopicArn: "arn:aws:sns:us-east-1:000000000000:test", + }; + + const stream = [ + createReadableStream(input), + awsSNSPublishMessageStream(options), + ]; + await pipeline(stream); + + const calls = client.commandCalls(PublishBatchCommand); + deepStrictEqual(calls.length > 1, true); + for (const call of calls) { + deepStrictEqual( + call.args[0].input.PublishBatchRequestEntries.length <= 2, + true, + ); + } +}); + +test(`${variant}: awsSNSPublishMessageStream first retry backoff is floored at 50ms (no 1ms hammer)`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + + const client = mockClient(SNSClient); + awsSNSSetClient(client); + + client + .on(PublishBatchCommand) + .resolvesOnce({ + Successful: [], + Failed: [{ Id: "0", Code: "ThrottlingException", SenderFault: false }], + }) + .resolves({ Successful: [{ Id: "0" }], Failed: [] }); + + const input = [{ Id: "0", Message: "a" }]; + const options = { + TopicArn: "arn:aws:sns:us-east-1:000000000000:test", + }; + const stream = [ + createReadableStream(input), + awsSNSPublishMessageStream(options), + ]; + const consuming = pipeline(stream); + + // Let the first send + partial-failure path schedule the backoff timer. + await new Promise((resolve) => setImmediate(resolve)); + // At 1ms the retry must NOT have fired (old behavior was 3**0 == 1ms). + t.mock.timers.tick(1); + await new Promise((resolve) => setImmediate(resolve)); + deepStrictEqual(client.commandCalls(PublishBatchCommand).length, 1); + + // Advancing to the 50ms floor lets the retry proceed. + t.mock.timers.tick(49); + await consuming; + deepStrictEqual(client.commandCalls(PublishBatchCommand).length, 2); +}); + +test(`${variant}: awsSNSPublishMessageStream should reject a single entry over 256KB`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + // A single message whose serialized size already exceeds the 256KB batch + // limit must be rejected with a descriptive error instead of being sent and + // having SNS reject the whole batch. + const tooBig = "x".repeat(256 * 1024 + 1); + const input = [{ Id: "0", Message: tooBig }]; + const options = { + TopicArn: "arn:aws:sns:us-east-1:000000000000:test", + }; + + const stream = [ + createReadableStream(input), + awsSNSPublishMessageStream(options), + ]; + await rejects(() => pipeline(stream), { + message: "awsSNSPublishBatch entry exceeds 256KiB limit", + }); + // The oversize entry must NOT have been sent to SNS. + deepStrictEqual(client.commandCalls(PublishBatchCommand).length, 0); +}); + +test(`${variant}: awsSNSPublishMessageStream should not mutate caller options`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + const options = { + TopicArn: "arn:aws:sns:us-east-1:000000000000:test", + }; + const optionsCopy = { ...options }; + const input = [{ Id: "0", Message: "a" }]; + + const stream = [ + createReadableStream(input), + awsSNSPublishMessageStream(options), + ]; + await pipeline(stream); + + deepStrictEqual(options, optionsCopy); +}); + +// *** Error cause carries the Failed subset *** // +test(`${variant}: awsSNSPublishMessageStream error cause is the Failed entries array`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + + const failed = [ + { Id: "0", Code: "InternalError", SenderFault: false, Message: "boom" }, + ]; + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: failed }); + + await rejects( + () => + pipeline([ + createReadableStream([{ Id: "0", Message: "a" }]), + awsSNSPublishMessageStream({ TopicArn: "t", retryMaxCount: 0 }), + ]), + (error) => { + deepStrictEqual(error.message, "awsSNSPublishBatch has failed entries"); + deepStrictEqual(error.cause, failed); + return true; + }, + ); +}); + +// *** Oversize entry: error cause shape and chunk?.Id *** // +test(`${variant}: awsSNSPublishMessageStream oversize error cause carries Id, bytes and limit`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + const tooBig = "x".repeat(256 * 1024 + 1); + const chunk = { Id: "the-id", Message: tooBig }; + const expectedBytes = new TextEncoder().encode( + JSON.stringify(chunk), + ).byteLength; + + await rejects( + () => + pipeline([ + createReadableStream([chunk]), + awsSNSPublishMessageStream({ TopicArn: "t" }), + ]), + (error) => { + deepStrictEqual( + error.message, + "awsSNSPublishBatch entry exceeds 256KiB limit", + ); + deepStrictEqual(error.cause, { + Id: "the-id", + bytes: expectedBytes, + limit: 256 * 1024, + }); + return true; + }, + ); +}); + +// *** Per-entry boundary: exactly 256KiB is allowed (strict `>`) *** // +test(`${variant}: awsSNSPublishMessageStream allows an entry whose serialized size is exactly 256KiB`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + // Build a chunk whose JSON.stringify byteLength is exactly 256*1024. + const limit = 256 * 1024; + const envelope = JSON.stringify({ Id: "0", Message: "" }).length; + const body = "x".repeat(limit - envelope); + const chunk = { Id: "0", Message: body }; + const bytes = new TextEncoder().encode(JSON.stringify(chunk)).byteLength; + deepStrictEqual(bytes, limit); // sanity: exactly at the limit + + await pipeline([ + createReadableStream([chunk]), + awsSNSPublishMessageStream({ TopicArn: "t" }), + ]); + + // Exactly at the limit is allowed (strict `>`), so it is sent. + deepStrictEqual(client.commandCalls(PublishBatchCommand).length, 1); +}); + +// *** Count cap split at exactly 10 entries *** // +test(`${variant}: awsSNSPublishMessageStream flushes at exactly 10 entries`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + // 11 tiny entries: the count cap (10) forces a flush of 10, then 1. + const input = Array.from({ length: 11 }, (_v, i) => ({ + Id: `${i}`, + Message: "x", + })); + await pipeline([ + createReadableStream(input), + awsSNSPublishMessageStream({ TopicArn: "t" }), + ]); + + const calls = client.commandCalls(PublishBatchCommand); + deepStrictEqual(calls.length, 2); + deepStrictEqual(calls[0].args[0].input.PublishBatchRequestEntries.length, 10); + deepStrictEqual(calls[1].args[0].input.PublishBatchRequestEntries.length, 1); +}); + +// *** retryMaxCount is an inclusive ceiling on the number of retries *** // +test(`${variant}: awsSNSPublishMessageStream retries exactly retryMaxCount times then throws`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ + Successful: [], + Failed: [{ Id: "0", Code: "InternalError", SenderFault: false }], + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ Id: "0", Message: "a" }]), + awsSNSPublishMessageStream({ TopicArn: "t", retryMaxCount: 2 }), + ]), + { message: "awsSNSPublishBatch has failed entries" }, + ); + // initial attempt + 2 retries == 3 calls (kills `>=`->`>` and `++`->`--`). + deepStrictEqual(client.commandCalls(PublishBatchCommand).length, 3); +}); + +test(`${variant}: awsSNSPublishMessageStream with retryMaxCount=1 attempts exactly twice`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ + Successful: [], + Failed: [{ Id: "0", Code: "InternalError", SenderFault: false }], + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ Id: "0", Message: "a" }]), + awsSNSPublishMessageStream({ TopicArn: "t", retryMaxCount: 1 }), + ]), + { message: "awsSNSPublishBatch has failed entries" }, + ); + deepStrictEqual(client.commandCalls(PublishBatchCommand).length, 2); +}); + +// *** batch byte-cap is exclusive: two entries summing to exactly 256KiB stay +// together in a single batch (strict `>`) *** // +test(`${variant}: awsSNSPublishMessageStream keeps two entries summing to exactly 256KiB in one batch`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + const limit = 256 * 1024; + const enc = (c) => new TextEncoder().encode(JSON.stringify(c)).byteLength; + const first = { Id: "0", Message: "x".repeat(100 * 1024) }; + const firstBytes = enc(first); + const envelope = enc({ Id: "1", Message: "" }); + const second = { + Id: "1", + Message: "y".repeat(limit - firstBytes - envelope), + }; + deepStrictEqual(firstBytes + enc(second), limit); // sanity: exactly the cap + + await pipeline([ + createReadableStream([first, second]), + awsSNSPublishMessageStream({ TopicArn: "t" }), + ]); + + // Sum is exactly at the cap: `> cap` is false, both go in ONE batch. A + // `>= cap` mutant would split them. + const calls = client.commandCalls(PublishBatchCommand); + deepStrictEqual(calls.length, 1); + deepStrictEqual(calls[0].args[0].input.PublishBatchRequestEntries.length, 2); +}); + +// *** setClient swaps the active client *** // +test(`${variant}: awsSNSSetClient routes subsequent sends to the new client`, async (_t) => { + const first = mockClient(SNSClient); + awsSNSSetClient(first); + first.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + const second = mockClient(SNSClient); + second.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + awsSNSSetClient(second); + + await pipeline([ + createReadableStream([{ Id: "0", Message: "a" }]), + awsSNSPublishMessageStream({ TopicArn: "t" }), + ]); + + deepStrictEqual(second.commandCalls(PublishBatchCommand).length, 1); + deepStrictEqual(first.commandCalls(PublishBatchCommand).length, 0); +}); + +// setClient must STORE the passed client; a plain stub (prototype-mock-proof) +// proves the stored reference is used. A `setClient(){}` mutant leaves the prior +// client in place and the stub's send would never run. +test(`${variant}: awsSNSSetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { Successful: [{ Id: "0" }], Failed: [] }; + }, + }; + awsSNSSetClient(stub); + + await pipeline([ + createReadableStream([{ Id: "0", Message: "a" }]), + awsSNSPublishMessageStream({ TopicArn: "t" }), + ]); + + deepStrictEqual(calls, 1); +}); + +// *** AbortSignal forwarding *** // +test(`${variant}: awsSNSPublishMessageStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + client.on(PublishBatchCommand).resolves({ Successful: [], Failed: [] }); + + const controller = new AbortController(); + await pipeline([ + createReadableStream([{ Id: "0", Message: "a" }]), + awsSNSPublishMessageStream( + { TopicArn: "t" }, + { signal: controller.signal }, + ), + ]); + + const calls = client.commandCalls(PublishBatchCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +// *** Backoff honours the abort signal: aborting mid-backoff rejects instead +// of completing the delay and retrying (kills the backoff timeout-options +// ObjectLiteral mutant that would drop `signal`). *** // +test(`${variant}: awsSNSPublishMessageStream aborts a pending retry backoff via signal`, async (_t) => { + const client = mockClient(SNSClient); + awsSNSSetClient(client); + + // Persistent partial failure so the stream stays in the retry/backoff loop. + let sends = 0; + client.on(PublishBatchCommand).callsFake(() => { + sends++; + return Promise.resolve({ + Successful: [], + Failed: [{ Id: "0", Code: "ThrottlingException", SenderFault: false }], + }); + }); + + const controller = new AbortController(); + const consuming = pipeline([ + createReadableStream([{ Id: "0", Message: "a" }]), + awsSNSPublishMessageStream( + { TopicArn: "t" }, + { signal: controller.signal }, + ), + ]); + + // Let the first send + partial-failure path schedule the backoff timer, then + // abort while it is pending. + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); + + // The backoff timeout was given `{ signal }`, so the abort rejects the pending + // backoff immediately and NO retry PublishBatch is issued. A `{}` mutant + // (dropping the signal) leaves the backoff running so it wakes and retries + // repeatedly during this window -> far more than one send. + await new Promise((resolve) => setTimeout(resolve, 300)); + deepStrictEqual(sends, 1); +}); + test(`${variant}: default export should include all stream functions`, (_t) => { deepStrictEqual(Object.keys(snsDefault).sort(), [ "publishMessageStream", diff --git a/packages/aws/sqs.js b/packages/aws/sqs.js index 2b623ea..146c869 100644 --- a/packages/aws/sqs.js +++ b/packages/aws/sqs.js @@ -6,9 +6,32 @@ import { SendMessageBatchCommand, SQSClient, } from "@aws-sdk/client-sqs"; -import { createWritableStream } from "@datastream/core"; +import { createWritableStream, timeout } from "@datastream/core"; import { awsClientDefaults } from "./client.js"; +// SendMessageBatch/DeleteMessageBatch: <=10 entries, <=256KB aggregate payload. +const SQS_MAX_ENTRIES = 10; +const SQS_MAX_BATCH_BYTES = 256 * 1024; + +// Partial failures are overwhelmingly throttling-driven; a near-zero early +// delay (3^0 == 1ms) just hammers the throttled endpoint. Apply a floor so the +// first retries give capacity time to recover, while preserving the ~59sec cap. +const BACKOFF_FLOOR_MS = 50; +const BACKOFF_CAP_MS = 3 ** 10; +// streamOptions is always supplied by the exported stream functions (defaulting +// to {}), so it is never nullish here. +const backoff = (retryCount, streamOptions) => + timeout( + Math.min(BACKOFF_CAP_MS, Math.max(BACKOFF_FLOOR_MS, 3 ** retryCount)), + { + signal: streamOptions.signal, + }, + ); + +const _textEncoder = new TextEncoder(); +const _byteLength = (chunk) => + _textEncoder.encode(JSON.stringify(chunk)).byteLength; + let client = new SQSClient(awsClientDefaults); export const awsSQSSetClient = (sqsClient) => { client = sqsClient; @@ -31,47 +54,96 @@ export const awsSQSReceiveMessageStream = async ( } expectMore = pollingActive || messages.length > 0; if (pollingActive && messages.length === 0 && pollingDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, pollingDelay)); + // Abortable idle wait: rejects immediately and clears the timer + // when streamOptions.signal aborts mid-delay. + await timeout(pollingDelay, { signal: streamOptions.signal }); } } } return command(sqsOptions); }; -export const awsSQSDeleteMessageStream = (options, streamOptions = {}) => { +// Shared batch writer that flushes on count or byte limits and retries the +// per-entry `Failed` subset (correlated by `Id`) with exponential backoff. +const sqsBatchStream = ( + Command, + errorMessage, + oversizeMessage, + options, + streamOptions, +) => { + const { retryMaxCount = 10, ...sendOptions } = options; let batch = []; - const send = () => { - options.Entries = batch; - batch = []; - return client.send(new DeleteMessageBatchCommand(options)); - }; - const write = async (chunk) => { - if (batch.length === 10) { - await send(); + let batchBytes = 0; + const send = async () => { + if (!batch.length) { + return; } - batch.push(chunk); - }; - const final = () => (batch.length ? send() : undefined); - return createWritableStream(write, final, streamOptions); -}; - -export const awsSQSSendMessageStream = (options, streamOptions = {}) => { - let batch = []; - const send = () => { - options.Entries = batch; + let entries = batch; batch = []; - return client.send(new SendMessageBatchCommand(options)); + batchBytes = 0; + let retryCount = 0; + while (true) { + const response = await client.send( + new Command({ ...sendOptions, Entries: entries }), + { abortSignal: streamOptions.signal }, + ); + const failed = response.Failed ?? []; + if (!failed.length) { + return; + } + const failedIds = new Set(failed.map((entry) => entry.Id)); + const failedEntries = entries.filter((entry) => failedIds.has(entry.Id)); + if (retryCount >= retryMaxCount) { + throw new Error(errorMessage, { cause: failed }); + } + await backoff(retryCount, streamOptions); + retryCount++; + entries = failedEntries; + } }; const write = async (chunk) => { - if (batch.length === 10) { + const chunkBytes = _byteLength(chunk); + // Surface an oversize single entry up front (mirroring Kinesis) instead of + // letting SQS reject the whole batch with a BatchRequestTooLong error. + if (chunkBytes > SQS_MAX_BATCH_BYTES) { + throw new Error(oversizeMessage, { + // Reached only for an oversize entry (a non-nullish object), so + // reading chunk.Id directly is safe. + cause: { Id: chunk.Id, bytes: chunkBytes, limit: SQS_MAX_BATCH_BYTES }, + }); + } + if ( + batch.length === SQS_MAX_ENTRIES || + (batch.length && batchBytes + chunkBytes > SQS_MAX_BATCH_BYTES) + ) { await send(); } batch.push(chunk); + batchBytes += chunkBytes; }; - const final = () => (batch.length ? send() : undefined); + const final = () => send(); return createWritableStream(write, final, streamOptions); }; +export const awsSQSDeleteMessageStream = (options, streamOptions = {}) => + sqsBatchStream( + DeleteMessageBatchCommand, + "awsSQSDeleteMessageBatch has failed entries", + "awsSQSDeleteMessageBatch entry exceeds 256KiB limit", + options, + streamOptions, + ); + +export const awsSQSSendMessageStream = (options, streamOptions = {}) => + sqsBatchStream( + SendMessageBatchCommand, + "awsSQSSendMessageBatch has failed entries", + "awsSQSSendMessageBatch entry exceeds 256KiB limit", + options, + streamOptions, + ); + export default { setClient: awsSQSSetClient, sendMessageStream: awsSQSSendMessageStream, diff --git a/packages/aws/sqs.test.js b/packages/aws/sqs.test.js index de05ba7..960037d 100644 --- a/packages/aws/sqs.test.js +++ b/packages/aws/sqs.test.js @@ -1,4 +1,4 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import test from "node:test"; import { DeleteMessageBatchCommand, @@ -118,6 +118,32 @@ test(`${variant}: awsSQSReceiveMessageStream should delay polling when pollingAc deepStrictEqual(output, [{ id: "a" }]); }); +test(`${variant}: awsSQSReceiveMessageStream should abort the idle poll delay on signal`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + + client.on(ReceiveMessageCommand).resolves({ Messages: [] }); + + const controller = new AbortController(); + const options = { pollingActive: true, pollingDelay: 60_000 }; + const stream = await awsSQSReceiveMessageStream(options, { + signal: controller.signal, + }); + + const consuming = (async () => { + for await (const _item of stream) { + // queue is always empty + } + })(); + + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + test(`${variant}: awsSQSReceiveMessageStream should not delay polling when messages are returned`, async (_t) => { const client = mockClient(SQSClient); awsSQSSetClient(client); @@ -232,6 +258,203 @@ test(`${variant}: awsSQSSendMessageStream should handle empty input`, async (_t) deepStrictEqual(result, {}); }); +// *** Failed entries (data loss) *** // +test(`${variant}: awsSQSSendMessageStream should retry failed entries`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + + const input = [ + { Id: "0", MessageBody: "a" }, + { Id: "1", MessageBody: "b" }, + ]; + const options = { + QueueUrl: "https://sqs.us-east-1.amazonaws.com/000000000000/test", + }; + + client + .on(SendMessageBatchCommand) + .resolvesOnce({ + Successful: [{ Id: "0" }], + Failed: [{ Id: "1", Code: "ThrottlingException", SenderFault: false }], + }) + .resolvesOnce({ + Successful: [{ Id: "1" }], + Failed: [], + }); + + const stream = [ + createReadableStream(input), + awsSQSSendMessageStream(options), + ]; + await pipeline(stream); + + const calls = client.commandCalls(SendMessageBatchCommand); + deepStrictEqual(calls.length, 2); + deepStrictEqual(calls[1].args[0].input.Entries, [ + { Id: "1", MessageBody: "b" }, + ]); +}); + +test(`${variant}: awsSQSSendMessageStream should throw when entries keep failing`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + + const input = [{ Id: "0", MessageBody: "a" }]; + const options = { + QueueUrl: "https://sqs.us-east-1.amazonaws.com/000000000000/test", + retryMaxCount: 0, + }; + + client.on(SendMessageBatchCommand).resolves({ + Successful: [], + Failed: [{ Id: "0", Code: "InternalError", SenderFault: false }], + }); + + const stream = [ + createReadableStream(input), + awsSQSSendMessageStream(options), + ]; + await rejects(() => pipeline(stream), { + message: "awsSQSSendMessageBatch has failed entries", + }); +}); + +test(`${variant}: awsSQSDeleteMessageStream should throw when entries keep failing`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + + const input = [{ Id: "0", ReceiptHandle: "r" }]; + const options = { + QueueUrl: "https://sqs.us-east-1.amazonaws.com/000000000000/test", + retryMaxCount: 0, + }; + + client.on(DeleteMessageBatchCommand).resolves({ + Successful: [], + Failed: [{ Id: "0", Code: "InternalError", SenderFault: false }], + }); + + const stream = [ + createReadableStream(input), + awsSQSDeleteMessageStream(options), + ]; + await rejects(() => pipeline(stream), { + message: "awsSQSDeleteMessageBatch has failed entries", + }); +}); + +test(`${variant}: awsSQSSendMessageStream should flush before exceeding 256KB`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + + client.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + // 5 messages of ~100KB each => 500KB total, well under count of 10 + // but over the 256KB byte limit, forcing multiple flushes. + const body = "x".repeat(100 * 1024); + const input = Array.from({ length: 5 }, (_v, i) => ({ + Id: `${i}`, + MessageBody: body, + })); + const options = { + QueueUrl: "https://sqs.us-east-1.amazonaws.com/000000000000/test", + }; + + const stream = [ + createReadableStream(input), + awsSQSSendMessageStream(options), + ]; + await pipeline(stream); + + const calls = client.commandCalls(SendMessageBatchCommand); + deepStrictEqual(calls.length > 1, true); + for (const call of calls) { + deepStrictEqual(call.args[0].input.Entries.length <= 2, true); + } +}); + +test(`${variant}: awsSQSSendMessageStream first retry backoff is floored at 50ms (no 1ms hammer)`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + + const client = mockClient(SQSClient); + awsSQSSetClient(client); + + client + .on(SendMessageBatchCommand) + .resolvesOnce({ + Successful: [], + Failed: [{ Id: "0", Code: "ThrottlingException", SenderFault: false }], + }) + .resolves({ Successful: [{ Id: "0" }], Failed: [] }); + + const input = [{ Id: "0", MessageBody: "a" }]; + const options = { + QueueUrl: "https://sqs.us-east-1.amazonaws.com/000000000000/test", + }; + const stream = [ + createReadableStream(input), + awsSQSSendMessageStream(options), + ]; + const consuming = pipeline(stream); + + // Let the first send + partial-failure path schedule the backoff timer. + await new Promise((resolve) => setImmediate(resolve)); + // At 1ms the retry must NOT have fired (old behavior was 3**0 == 1ms). + t.mock.timers.tick(1); + await new Promise((resolve) => setImmediate(resolve)); + deepStrictEqual(client.commandCalls(SendMessageBatchCommand).length, 1); + + // Advancing to the 50ms floor lets the retry proceed. + t.mock.timers.tick(49); + await consuming; + deepStrictEqual(client.commandCalls(SendMessageBatchCommand).length, 2); +}); + +test(`${variant}: awsSQSSendMessageStream should reject a single entry over 256KB`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + // A single message whose serialized size already exceeds the 256KB batch + // limit must be rejected with a descriptive error instead of being sent and + // having SQS reject the whole batch. + const tooBig = "x".repeat(256 * 1024 + 1); + const input = [{ Id: "0", MessageBody: tooBig }]; + const options = { + QueueUrl: "https://sqs.us-east-1.amazonaws.com/000000000000/test", + }; + + const stream = [ + createReadableStream(input), + awsSQSSendMessageStream(options), + ]; + await rejects(() => pipeline(stream), { + message: "awsSQSSendMessageBatch entry exceeds 256KiB limit", + }); + // The oversize entry must NOT have been sent to SQS. + deepStrictEqual(client.commandCalls(SendMessageBatchCommand).length, 0); +}); + +test(`${variant}: awsSQSSendMessageStream should not mutate caller options`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + const options = { + QueueUrl: "https://sqs.us-east-1.amazonaws.com/000000000000/test", + }; + const optionsCopy = { ...options }; + const input = [{ Id: "0", MessageBody: "a" }]; + + const stream = [ + createReadableStream(input), + awsSQSSendMessageStream(options), + ]; + await pipeline(stream); + + deepStrictEqual(options, optionsCopy); +}); + test(`${variant}: default export should include all stream functions`, (_t) => { deepStrictEqual(Object.keys(sqsDefault).sort(), [ "deleteMessageStream", @@ -240,3 +463,387 @@ test(`${variant}: default export should include all stream functions`, (_t) => { "setClient", ]); }); + +// *** setClient swaps the active client *** // +test(`${variant}: awsSQSSetClient routes subsequent sends to the new client`, async (_t) => { + const first = mockClient(SQSClient); + awsSQSSetClient(first); + first.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + const second = mockClient(SQSClient); + second.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + awsSQSSetClient(second); + + await pipeline([ + createReadableStream([{ Id: "0", MessageBody: "a" }]), + awsSQSSendMessageStream({ QueueUrl: "q" }), + ]); + + deepStrictEqual(second.commandCalls(SendMessageBatchCommand).length, 1); + deepStrictEqual(first.commandCalls(SendMessageBatchCommand).length, 0); +}); + +// setClient must STORE the passed client; a plain stub (prototype-mock-proof) +// proves the stored reference is used. A `setClient(){}` mutant leaves the prior +// client in place. +test(`${variant}: awsSQSSetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { Messages: [{ id: "stub" }] }; + }, + }; + awsSQSSetClient(stub); + + // Not polling: one page with messages then the generator stops when the next + // (default) empty page arrives — but with a single resolved value the stub + // returns messages each call, so cap the consumption. + const stream = await awsSQSReceiveMessageStream({}); + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 1) break; + } + + deepStrictEqual(output, [{ id: "stub" }]); + deepStrictEqual(calls, 1); +}); + +// *** idle-wait guard conjuncts (mock timers) *** // +test(`${variant}: awsSQSReceiveMessageStream does not schedule a timer when messages are present`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(SQSClient); + awsSQSSetClient(client); + // Messages always present so `messages.length === 0` is false -> no idle wait. + // A `true` mutant on that conjunct would delay after every poll and the + // consumer would stall on the un-ticked mock timer. + client.on(ReceiveMessageCommand).resolves({ Messages: [{ id: "a" }] }); + + const options = { pollingActive: true, pollingDelay: 60_000 }; + const stream = await awsSQSReceiveMessageStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 3) break; + } + deepStrictEqual(output.length, 3); +}); + +test(`${variant}: awsSQSReceiveMessageStream pollingDelay 0 schedules no timer`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(SQSClient); + awsSQSSetClient(client); + // pollingActive, empty queue, pollingDelay === 0: `pollingDelay > 0` is false + // so no idle wait. A `> 0` -> `true` or `>= 0` mutant would await timeout(0) + // which the un-ticked mock timer never resolves. + client + .on(ReceiveMessageCommand) + .resolvesOnce({ Messages: [] }) + .resolves({ Messages: [{ id: "a" }] }); + + const options = { pollingActive: true, pollingDelay: 0 }; + const stream = await awsSQSReceiveMessageStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 1) break; + } + deepStrictEqual(output, [{ id: "a" }]); +}); + +test(`${variant}: awsSQSReceiveMessageStream non-polling empty page ends without an idle wait`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(SQSClient); + awsSQSSetClient(client); + // Not polling: an empty page ends the stream WITHOUT entering the idle wait. + // The `&&` -> `||` mutant makes `pollingActive || messages.length === 0` true + // on the empty page, scheduling a (default 1000ms) timeout the un-ticked mock + // timer never resolves -> the stream would hang instead of completing. + client + .on(ReceiveMessageCommand) + .resolvesOnce({ Messages: [{ id: "a" }] }) + .resolves({ Messages: [] }); + + const stream = await awsSQSReceiveMessageStream({}); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ id: "a" }]); +}); + +// *** backoff is abortable (signal forwarded to the timeout) *** // +test(`${variant}: awsSQSSendMessageStream aborts during retry backoff`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + // Persistent partial failure so the stream enters the (real-timer) backoff. + client.on(SendMessageBatchCommand).resolves({ + Successful: [], + Failed: [{ Id: "0", Code: "ThrottlingException", SenderFault: false }], + }); + + const controller = new AbortController(); + const consuming = pipeline([ + createReadableStream([{ Id: "0", MessageBody: "a" }]), + awsSQSSendMessageStream({ QueueUrl: "q" }, { signal: controller.signal }), + ]); + + // Let the first send fail and enter the backoff timer, then abort. The backoff + // timeout was given `{ signal }`; a `{}` mutant (dropping the signal) would let + // the backoff run and keep retrying, so this would never reject. + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + +// *** AbortSignal forwarding *** // +test(`${variant}: awsSQSReceiveMessageStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client + .on(ReceiveMessageCommand) + .resolvesOnce({ Messages: [{ id: "a" }] }) + .resolves({ Messages: [] }); + + const controller = new AbortController(); + const stream = await awsSQSReceiveMessageStream( + {}, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(ReceiveMessageCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +test(`${variant}: awsSQSSendMessageStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + const controller = new AbortController(); + await pipeline([ + createReadableStream([{ Id: "0", MessageBody: "a" }]), + awsSQSSendMessageStream({ QueueUrl: "q" }, { signal: controller.signal }), + ]); + + const calls = client.commandCalls(SendMessageBatchCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +// *** Error cause carries the Failed subset *** // +test(`${variant}: awsSQSSendMessageStream error cause is the Failed entries array`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + + const failed = [ + { Id: "0", Code: "InternalError", SenderFault: false, Message: "boom" }, + ]; + client + .on(SendMessageBatchCommand) + .resolves({ Successful: [], Failed: failed }); + + await rejects( + () => + pipeline([ + createReadableStream([{ Id: "0", MessageBody: "a" }]), + awsSQSSendMessageStream({ QueueUrl: "q", retryMaxCount: 0 }), + ]), + (error) => { + deepStrictEqual( + error.message, + "awsSQSSendMessageBatch has failed entries", + ); + deepStrictEqual(error.cause, failed); + return true; + }, + ); +}); + +// *** Oversize entry: error cause shape and chunk?.Id *** // +test(`${variant}: awsSQSSendMessageStream oversize error cause carries Id, bytes and limit`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + const tooBig = "x".repeat(256 * 1024 + 1); + const chunk = { Id: "the-id", MessageBody: tooBig }; + const expectedBytes = new TextEncoder().encode( + JSON.stringify(chunk), + ).byteLength; + + await rejects( + () => + pipeline([ + createReadableStream([chunk]), + awsSQSSendMessageStream({ QueueUrl: "q" }), + ]), + (error) => { + deepStrictEqual( + error.message, + "awsSQSSendMessageBatch entry exceeds 256KiB limit", + ); + deepStrictEqual(error.cause, { + Id: "the-id", + bytes: expectedBytes, + limit: 256 * 1024, + }); + return true; + }, + ); +}); + +// *** Per-entry boundary: exactly 256KiB is allowed (strict `>`) *** // +test(`${variant}: awsSQSSendMessageStream allows an entry whose serialized size is exactly 256KiB`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + // Build a chunk whose JSON.stringify byteLength is exactly 256*1024. + const limit = 256 * 1024; + const envelope = JSON.stringify({ Id: "0", MessageBody: "" }).length; // 24 + const body = "x".repeat(limit - envelope); + const chunk = { Id: "0", MessageBody: body }; + const bytes = new TextEncoder().encode(JSON.stringify(chunk)).byteLength; + deepStrictEqual(bytes, limit); // sanity: exactly at the limit + + await pipeline([ + createReadableStream([chunk]), + awsSQSSendMessageStream({ QueueUrl: "q" }), + ]); + + // Exactly at the limit is allowed (strict `>`), so it is sent. + deepStrictEqual(client.commandCalls(SendMessageBatchCommand).length, 1); +}); + +// *** Delete oversize path uses the Delete-specific message *** // +test(`${variant}: awsSQSDeleteMessageStream rejects an oversize entry with the delete message`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(DeleteMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + const tooBig = "x".repeat(256 * 1024 + 1); + await rejects( + () => + pipeline([ + createReadableStream([{ Id: "0", ReceiptHandle: tooBig }]), + awsSQSDeleteMessageStream({ QueueUrl: "q" }), + ]), + { message: "awsSQSDeleteMessageBatch entry exceeds 256KiB limit" }, + ); + deepStrictEqual(client.commandCalls(DeleteMessageBatchCommand).length, 0); +}); + +// *** Count cap split at exactly 10 entries *** // +test(`${variant}: awsSQSSendMessageStream flushes at exactly 10 entries`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + // 11 tiny entries: the count cap (10) forces a flush of 10, then 1. + const input = Array.from({ length: 11 }, (_v, i) => ({ + Id: `${i}`, + MessageBody: "x", + })); + await pipeline([ + createReadableStream(input), + awsSQSSendMessageStream({ QueueUrl: "q" }), + ]); + + const calls = client.commandCalls(SendMessageBatchCommand); + deepStrictEqual(calls.length, 2); + deepStrictEqual(calls[0].args[0].input.Entries.length, 10); + deepStrictEqual(calls[1].args[0].input.Entries.length, 1); +}); + +// *** Receive without pollingActive stops once the queue drains *** // +test(`${variant}: awsSQSReceiveMessageStream without pollingActive stops when queue empties`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client + .on(ReceiveMessageCommand) + .resolvesOnce({ Messages: [{ id: "a" }] }) + .resolves({ Messages: [] }); + + const stream = await awsSQSReceiveMessageStream({}); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ id: "a" }]); + // One call returns a message (keep going), the next is empty (stop): + // expectMore = pollingActive(false) || messages.length > 0. + deepStrictEqual(client.commandCalls(ReceiveMessageCommand).length, 2); +}); + +// *** retryMaxCount is an inclusive ceiling on the number of retries *** // +test(`${variant}: awsSQSSendMessageStream retries exactly retryMaxCount times then throws`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ + Successful: [], + Failed: [{ Id: "0", Code: "InternalError", SenderFault: false }], + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ Id: "0", MessageBody: "a" }]), + awsSQSSendMessageStream({ QueueUrl: "q", retryMaxCount: 2 }), + ]), + { message: "awsSQSSendMessageBatch has failed entries" }, + ); + // initial attempt + 2 retries == 3 calls (kills `>=`->`>` and `++`->`--`). + deepStrictEqual(client.commandCalls(SendMessageBatchCommand).length, 3); +}); + +test(`${variant}: awsSQSSendMessageStream with retryMaxCount=1 attempts exactly twice`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ + Successful: [], + Failed: [{ Id: "0", Code: "InternalError", SenderFault: false }], + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ Id: "0", MessageBody: "a" }]), + awsSQSSendMessageStream({ QueueUrl: "q", retryMaxCount: 1 }), + ]), + { message: "awsSQSSendMessageBatch has failed entries" }, + ); + deepStrictEqual(client.commandCalls(SendMessageBatchCommand).length, 2); +}); + +// *** batch byte-cap is exclusive: two entries summing to exactly 256KiB stay +// together in a single batch (strict `>`) *** // +test(`${variant}: awsSQSSendMessageStream keeps two entries summing to exactly 256KiB in one batch`, async (_t) => { + const client = mockClient(SQSClient); + awsSQSSetClient(client); + client.on(SendMessageBatchCommand).resolves({ Successful: [], Failed: [] }); + + const limit = 256 * 1024; + const enc = (c) => new TextEncoder().encode(JSON.stringify(c)).byteLength; + const first = { Id: "0", MessageBody: "x".repeat(100 * 1024) }; + const firstBytes = enc(first); + const envelope = enc({ Id: "1", MessageBody: "" }); + const second = { + Id: "1", + MessageBody: "y".repeat(limit - firstBytes - envelope), + }; + deepStrictEqual(firstBytes + enc(second), limit); // sanity: exactly the cap + + await pipeline([ + createReadableStream([first, second]), + awsSQSSendMessageStream({ QueueUrl: "q" }), + ]); + + // Sum is exactly at the cap: `> cap` is false, both go in ONE batch. A + // `>= cap` mutant would split them. + const calls = client.commandCalls(SendMessageBatchCommand); + deepStrictEqual(calls.length, 1); + deepStrictEqual(calls[0].args[0].input.Entries.length, 2); +}); diff --git a/packages/base64/README.md b/packages/base64/README.md index d5e9a8f..0b5c2db 100644 --- a/packages/base64/README.md +++ b/packages/base64/README.md @@ -1,6 +1,6 @@

<datastream> `base64`

- datastream logo + datastream logo

Base64 encoding and decoding streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/base64/index.node.js b/packages/base64/index.node.js index 05e9bc4..5dede21 100644 --- a/packages/base64/index.node.js +++ b/packages/base64/index.node.js @@ -10,6 +10,17 @@ const toBuffer = (chunk) => ? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) : Buffer.from(chunk); +// Valid base64 requires length to be a multiple of 4 and only valid alphabet +// chars, with at most 2 trailing '=' padding characters. This rejects short +// fragments like "YQ=" (length 3) and standalone padding like "==" (length 2) +// that Buffer.from leniently accepts but atob() in the Web build rejects. +const VALID_BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; +const assertValidBase64 = (s) => { + if (s.length % 4 !== 0 || !VALID_BASE64_RE.test(s)) { + throw new Error(`Invalid base64 string: ${JSON.stringify(s)}`); + } +}; + export const base64EncodeStream = (_options = {}, streamOptions = {}) => { let extra; // Buffer | undefined const transform = (chunk, enqueue) => { @@ -19,14 +30,14 @@ export const base64EncodeStream = (_options = {}, streamOptions = {}) => { extra = undefined; } const remaining = buf.length % 3; + const whole = buf.length - remaining; if (remaining > 0) { - extra = Buffer.from(buf.subarray(buf.length - remaining)); - buf = buf.subarray(0, buf.length - remaining); + extra = Buffer.from(buf.subarray(whole)); } - if (buf.length > 0) enqueue(buf.toString("base64")); + if (whole > 0) enqueue(buf.subarray(0, whole).toString("base64")); }; const flush = (enqueue) => { - if (extra && extra.length > 0) { + if (extra) { enqueue(extra.toString("base64")); } }; @@ -36,21 +47,25 @@ export const base64EncodeStream = (_options = {}, streamOptions = {}) => { export const base64DecodeStream = (_options = {}, streamOptions = {}) => { let extra = ""; const transform = (chunk, enqueue) => { - const str = - typeof chunk === "string" ? chunk : toBuffer(chunk).toString("ascii"); - let s = extra.length > 0 ? extra + str : str; - extra = ""; - const remaining = s.length % 4; - if (remaining > 0) { - extra = s.slice(s.length - remaining); - s = s.slice(0, s.length - remaining); + // Base64's alphabet is pure ASCII, so a string chunk and the ASCII byte + // view of a Buffer/Uint8Array chunk are interchangeable. Normalising every + // chunk through toBuffer().toString("ascii") keeps a single code path. + const str = toBuffer(chunk).toString("ascii"); + const s0 = extra + str; + const remaining = s0.length % 4; + const whole = s0.length - remaining; + extra = s0.slice(whole); + const s = s0.slice(0, whole); + if (s.length > 0) { + assertValidBase64(s); + enqueue(Buffer.from(s, "base64")); } - if (s.length > 0) enqueue(Buffer.from(s, "base64")); }; - const flush = (enqueue) => { - if (extra.length > 0) { - enqueue(Buffer.from(extra, "base64")); - } + const flush = () => { + // Any leftover characters form an incomplete quartet (length 1-3) and can + // never be a valid base64 group, so reject rather than silently drop them. + // assertValidBase64("") is a no-op, so no length guard is needed here. + assertValidBase64(extra); }; return createTransformStream(transform, flush, streamOptions); }; diff --git a/packages/base64/index.test.js b/packages/base64/index.test.js index 67d8719..e88ee32 100644 --- a/packages/base64/index.test.js +++ b/packages/base64/index.test.js @@ -8,6 +8,8 @@ import base64Default, { import { createReadableStream, pipejoin, + pipeline, + streamToArray, streamToBuffer, streamToString, } from "@datastream/core"; @@ -102,14 +104,17 @@ test(`${variant}: base64DecodeStream should decode partial base64`, async (_t) = deepStrictEqual(output, "hello"); }); -// Test decode flush with remaining characters +// Test decode flush with a valid padded quartet held across chunks test(`${variant}: base64DecodeStream should flush remaining characters`, async (_t) => { const input = btoa("ab"); // "YWI=" - const chunks = [input.slice(0, 2)]; // Only send "YW" + // Split so that the first chunk contributes only part of a 4-char group; + // the decoder should buffer "YW" and combine with "I=" in the second chunk + // to form the complete valid quartet "YWI=" before decoding. + const chunks = [input.slice(0, 2), input.slice(2)]; // "YW" + "I=" const streams = [createReadableStream(chunks), base64DecodeStream()]; const output = await streamToBuffer(pipejoin(streams)); - deepStrictEqual(output.byteLength, 1); + deepStrictEqual(output.byteLength, 2); // "ab" is 2 bytes }); // *** Binary correctness *** // @@ -150,3 +155,208 @@ test(`${variant}: default export should include all stream functions`, (_t) => { "encodeStream", ]); }); + +// *** Padding validation parity (HIGH finding) *** // +// These malformed inputs must be REJECTED identically on both Node and Web builds. +// "YQ=" is a 3-char fragment (not a multiple of 4) — Node's Buffer.from is lenient +// but atob() throws; after the fix BOTH builds must throw. +const malformedPaddingCases = [ + { input: "YQ=", label: "3-char group with single pad (YQ=)" }, + { input: "Pw=", label: "3-char group with single pad (Pw=)" }, + { input: "QQ=", label: "3-char group with single pad (QQ=)" }, + { input: "==", label: "padding-only (==)" }, + { input: "AAAA==", label: "length-6 with trailing padding (AAAA==)" }, +]; + +for (const { input, label } of malformedPaddingCases) { + test(`${variant}: base64DecodeStream should reject malformed padding: ${label}`, async (_t) => { + let threw = false; + try { + await pipeline([createReadableStream([input]), base64DecodeStream()]); + } catch (_e) { + threw = true; + } + deepStrictEqual( + threw, + true, + `Expected decode of ${JSON.stringify(input)} to throw`, + ); + }); +} + +// Valid padded inputs that must be ACCEPTED on both builds +const validPaddedCases = [ + { + input: "YQ==", + expected: [0x61], + label: "2-char group double-pad (YQ==) -> 'a'", + }, + { + input: "YWI=", + expected: [0x61, 0x62], + label: "3-char group single-pad (YWI=) -> 'ab'", + }, + { + input: "AAAA", + expected: [0x00, 0x00, 0x00], + label: "unpadded 4-char group (AAAA) -> zeros", + }, +]; + +for (const { input, expected, label } of validPaddedCases) { + test(`${variant}: base64DecodeStream should accept valid padded input: ${label}`, async (_t) => { + const streams = [createReadableStream([input]), base64DecodeStream()]; + const output = await streamToBuffer(pipejoin(streams)); + deepStrictEqual(Array.from(output), expected); + }); +} + +// *** Regex anchor coverage *** // +// Inputs with invalid characters at the START or END (length % 4 == 0) must be rejected. +// Without the leading ^ anchor the regex would match a valid suffix and accept '!AAA'. +// Without the trailing $ anchor the regex would match a valid prefix and accept 'AAA!'. +test(`${variant}: base64DecodeStream should reject input with invalid leading char`, async (_t) => { + let threw = false; + try { + await pipeline([createReadableStream(["!AAA"]), base64DecodeStream()]); + } catch (_e) { + threw = true; + } + deepStrictEqual(threw, true, "Expected decode of '!AAA' to throw"); +}); + +test(`${variant}: base64DecodeStream should reject input with invalid trailing char`, async (_t) => { + let threw = false; + try { + await pipeline([createReadableStream(["AAA!"]), base64DecodeStream()]); + } catch (_e) { + threw = true; + } + deepStrictEqual(threw, true, "Expected decode of 'AAA!' to throw"); +}); + +// *** Error message content *** // +// Verify that the thrown error identifies the bad input string (not an empty message). +test(`${variant}: base64DecodeStream error message should include input and label`, async (_t) => { + let errorMessage = ""; + try { + await pipeline([createReadableStream(["!AAA"]), base64DecodeStream()]); + } catch (e) { + errorMessage = e.message; + } + deepStrictEqual( + errorMessage.includes("Invalid base64 string"), + true, + "Error message must contain 'Invalid base64 string'", + ); + deepStrictEqual( + errorMessage.includes("!AAA"), + true, + "Error message must include the bad input", + ); +}); + +// *** Buffer chunk through decode stream *** // +// Passing a Buffer (non-string) chunk exercises the toBuffer().toString('ascii') branch. +// A StringLiteral mutation that replaces 'ascii' with '' causes ERR_UNKNOWN_ENCODING, +// so the correct code must succeed and produce the expected bytes. +test(`${variant}: base64DecodeStream should decode Buffer chunks correctly`, async (_t) => { + const originalText = "Hello, World!"; + const b64 = Buffer.from(originalText).toString("base64"); + // Pass the base64 string as a Buffer chunk (not a plain string) + const streams = [ + createReadableStream([Buffer.from(b64)]), + base64DecodeStream(), + ]; + const output = await streamToBuffer(pipejoin(streams)); + deepStrictEqual(output.toString(), originalText); +}); + +// *** Line-40 slice mutation: cross-boundary extra tracking *** // +// Split a 16-char base64 string at offset 6 (6 % 4 == 2 → remainder=2). +// The correct code stores only the LAST 2 chars as extra; the mutant stores the full +// 6-char string. On the second chunk the mutant concatenates the wrong prefix, +// decoding different bytes (producing "HelHello World!" instead of "Hello World!"). +test(`${variant}: base64DecodeStream should track extra correctly across non-mod4 boundaries`, async (_t) => { + const originalText = "Hello World!"; + const b64 = Buffer.from(originalText).toString("base64"); // 16 chars + // Split so first chunk length is 6 (6 % 4 == 2, non-zero remainder) + const chunks = [b64.slice(0, 6), b64.slice(6)]; + const streams = [createReadableStream(chunks), base64DecodeStream()]; + const output = await streamToBuffer(pipejoin(streams)); + deepStrictEqual(output.toString(), originalText); +}); + +// *** Per-chunk emission shape (encode) *** // +// streamToArray preserves the discrete chunks each transform/flush enqueues. +// A single 4-byte chunk must emit the 3-byte aligned group from transform and +// the held trailing byte from flush as TWO separate chunks. This pins down the +// `% 3` arithmetic, the remaining/whole guards, and the flush emission so that +// mutants which keep the same concatenation but change the split are killed. +test(`${variant}: base64EncodeStream should emit aligned group then trailing byte as separate chunks`, async (_t) => { + const input = Buffer.from("aaaa"); // 4 bytes -> 4 % 3 = 1 trailing byte + const streams = [createReadableStream([input]), base64EncodeStream()]; + const chunks = await streamToArray(pipejoin(streams)); + deepStrictEqual(chunks, [ + Buffer.from("aaa").toString("base64"), // "YWFh" + Buffer.from("a").toString("base64"), // "YQ==" + ]); +}); + +// A 3-byte aligned chunk emits exactly one chunk from transform and nothing +// from flush (extra stays undefined). Kills flush `if (extra) -> true` (which +// would call undefined.toString() and throw) and the encode guards that would +// emit an empty trailing chunk. +test(`${variant}: base64EncodeStream should emit a single chunk for aligned input`, async (_t) => { + const input = Buffer.from("aaa"); // 3 bytes, 3 % 3 = 0 + const streams = [createReadableStream([input]), base64EncodeStream()]; + const chunks = await streamToArray(pipejoin(streams)); + deepStrictEqual(chunks, [Buffer.from("aaa").toString("base64")]); +}); + +// A single 1-byte chunk holds the whole byte as extra: transform emits NOTHING +// (whole === 0) and flush emits the encoded byte. Kills `whole > 0 -> true` +// and `whole > 0 -> whole >= 0` (which would emit an empty "" chunk from the +// transform) and flush `if (extra) -> false` (which would drop the byte). +test(`${variant}: base64EncodeStream should emit only from flush for a single byte`, async (_t) => { + const input = Buffer.from("a"); // 1 byte, whole === 0 + const streams = [createReadableStream([input]), base64EncodeStream()]; + const chunks = await streamToArray(pipejoin(streams)); + deepStrictEqual(chunks, [Buffer.from("a").toString("base64")]); // ["YQ=="] +}); + +// *** Per-chunk emission shape (decode) *** // +// A short (< 4 char) single chunk holds everything as extra: transform emits +// NOTHING (whole === 0) and the leftover is rejected at flush. Kills the decode +// `s.length > 0 -> true / >= 0` mutants (which would enqueue an empty Buffer). +test(`${variant}: base64DecodeStream should not emit for a sub-quartet chunk`, async (_t) => { + const emitted = []; + let threw = false; + await new Promise((resolve) => { + const source = createReadableStream(["YQ"]); + const decode = base64DecodeStream(); + decode.on("data", (chunk) => emitted.push(chunk)); + decode.on("end", resolve); + decode.on("error", () => { + threw = true; + resolve(); + }); + source.pipe(decode); + }); + // "YQ" is an incomplete quartet: the transform must emit no chunk and the + // flush must reject it. Without the `s.length > 0` guard the transform would + // enqueue an empty Buffer before the flush rejection. + deepStrictEqual(threw, true, "Expected incomplete quartet to be rejected"); + deepStrictEqual(emitted, []); +}); + +// A single aligned 4-char chunk decodes to exactly one Buffer chunk from the +// transform; flush emits nothing (extra empty). Kills the `% 4 -> * 4` and +// `whole = length - remaining -> + remaining` mutants which change the split. +test(`${variant}: base64DecodeStream should emit a single buffer chunk for an aligned quartet`, async (_t) => { + const b64 = Buffer.from("abc").toString("base64"); // "YWJj", 4 chars + const streams = [createReadableStream([b64]), base64DecodeStream()]; + const chunks = await streamToArray(pipejoin(streams)); + deepStrictEqual(chunks.length, 1); + deepStrictEqual(Buffer.concat(chunks).toString(), "abc"); +}); diff --git a/packages/base64/index.web.js b/packages/base64/index.web.js index 9176bbd..ced3913 100644 --- a/packages/base64/index.web.js +++ b/packages/base64/index.web.js @@ -3,6 +3,18 @@ /* global btoa, atob */ import { createTransformStream } from "@datastream/core"; +// Valid base64 requires length to be a multiple of 4 and only valid alphabet +// chars, with at most 2 trailing '=' padding characters. This rejects short +// fragments like "YQ=" (length 3) and standalone padding like "==" (length 2) +// so that the Web build (atob) and the Node build (Buffer.from) behave +// identically on malformed input. +const VALID_BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; +const assertValidBase64 = (s) => { + if (s.length % 4 !== 0 || !VALID_BASE64_RE.test(s)) { + throw new Error(`Invalid base64 string: ${JSON.stringify(s)}`); + } +}; + const utf8Encoder = new TextEncoder(); const toBytes = (chunk) => { @@ -66,10 +78,14 @@ export const base64DecodeStream = (_options = {}, streamOptions = {}) => { extra = s.slice(s.length - remaining); s = s.slice(0, s.length - remaining); } - if (s.length > 0) enqueue(binaryStringToBytes(atob(s))); + if (s.length > 0) { + assertValidBase64(s); + enqueue(binaryStringToBytes(atob(s))); + } }; const flush = (enqueue) => { if (extra.length > 0) { + assertValidBase64(extra); enqueue(binaryStringToBytes(atob(extra))); } }; diff --git a/packages/base64/package.json b/packages/base64/package.json index c543062..62750fb 100644 --- a/packages/base64/package.json +++ b/packages/base64/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/base64", - "version": "0.5.0", + "version": "0.6.1", "description": "Base64 encoding and decoding transform streams", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,6 +61,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/charset/README.md b/packages/charset/README.md index e0ea83c..982999e 100644 --- a/packages/charset/README.md +++ b/packages/charset/README.md @@ -1,6 +1,6 @@

<datastream> `charset`

- datastream logo + datastream logo

Character encoding and decoding streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/charset/decode.node.js b/packages/charset/decode.node.js index 81b9525..135a246 100644 --- a/packages/charset/decode.node.js +++ b/packages/charset/decode.node.js @@ -12,15 +12,20 @@ export const charsetDecodeStream = ({ charset } = {}, streamOptions = {}) => { const conv = iconv.getDecoder(charset); const transform = (chunk, enqueue) => { + // conv.write() always returns a string (never nullish), so a plain + // .length check is enough. The stream is objectMode, so enqueue ignores + // any encoding argument; pass only the chunk. const res = conv.write(chunk); - if (res?.length) { - enqueue(res, "utf8"); + if (res.length) { + enqueue(res); } }; const flush = (enqueue) => { + // conv.end() can return undefined for some decoders (e.g. ISO-8859-1), + // so guard the length read with optional chaining. const res = conv.end(); if (res?.length) { - enqueue(res, "utf8"); + enqueue(res); } }; return createTransformStream(transform, flush, streamOptions); diff --git a/packages/charset/decode.web.js b/packages/charset/decode.web.js index 874e6bb..bbe240a 100644 --- a/packages/charset/decode.web.js +++ b/packages/charset/decode.web.js @@ -2,56 +2,26 @@ // SPDX-License-Identifier: MIT /* global TextDecoderStream */ -const supportedEncodings = new Set([ - "utf-8", - "utf-16le", - "utf-16be", - "ibm866", - "iso-8859-2", - "iso-8859-3", - "iso-8859-4", - "iso-8859-5", - "iso-8859-6", - "iso-8859-7", - "iso-8859-8", - "iso-8859-8-i", - "iso-8859-10", - "iso-8859-13", - "iso-8859-14", - "iso-8859-15", - "iso-8859-16", - "koi8-r", - "koi8-u", - "macintosh", - "windows-874", - "windows-1250", - "windows-1251", - "windows-1252", - "windows-1253", - "windows-1254", - "windows-1255", - "windows-1256", - "windows-1257", - "windows-1258", - "x-mac-cyrillic", - "gbk", - "gb18030", - "big5", - "euc-jp", - "iso-2022-jp", - "shift_jis", - "euc-kr", - "replacement", - "x-user-defined", -]); - export const charsetDecodeStream = ({ charset } = {}, _streamOptions = {}) => { - if (charset !== null && !supportedEncodings.has(charset.toLowerCase())) { + // Default/null charset means UTF-8, matching the node implementation which + // falls back to UTF-8 for unknown/missing encodings instead of crashing. + // `new TextDecoderStream(undefined)` already defaults to UTF-8, so normalise + // null to undefined. + if (charset === null || charset === undefined) { + return new TextDecoderStream(); + } + // Let TextDecoderStream validate the label rather than maintaining a manual + // allowlist that is stricter than the platform (it rejected valid labels such + // as ISO-8859-1 / ISO-8859-9 that TextDecoder accepts). The constructor + // throws a RangeError for genuinely unknown labels; rethrow with the + // package-branded message. + try { + return new TextDecoderStream(charset); + } catch { throw new Error( `charsetDecodeStream: Unsupported web encoding "${charset}"`, ); } - return new TextDecoderStream(charset); }; export default charsetDecodeStream; diff --git a/packages/charset/detect.js b/packages/charset/detect.js index bcd3466..ac78f77 100644 --- a/packages/charset/detect.js +++ b/packages/charset/detect.js @@ -22,40 +22,81 @@ const charsetKeys = [ "ISO-8859-5", "ISO-8859-6", "ISO-8859-7", - "ISO-8859-8-I", "ISO-8859-8", "windows-1251", "windows-1256", "windows-1252", "windows-1254", "windows-1250", - "KOIR8-R", + "KOI8-R", "ISO-8859-9", ]; +// Cap the detection sample so we never buffer an unbounded amount of the +// stream. chardet analyses a representative prefix; 64KB is plenty. +const MAX_DETECTION_SAMPLE = 64 * 1024; + +// chardet reports pure-ASCII input as "ASCII" (often at confidence 100). ASCII +// is a strict subset of UTF-8, so fold an ASCII match into the UTF-8 bucket +// rather than discarding the highest-confidence result and reporting a +// spurious ISO-8859-1 winner. +const normaliseMatchName = (name) => (name === "ASCII" ? "UTF-8" : name); + +// Concatenate the sampled Uint8Array chunks without relying on the node-only +// Buffer global, so the shared detect source runs in the browser too. String +// chunks are encoded as UTF-8 bytes via TextEncoder for the same reason. +const concatBytes = (chunks, totalLength) => { + const out = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +}; + export const charsetDetectStream = ({ resultKey } = {}, streamOptions = {}) => { - const charsets = Object.fromEntries(charsetKeys.map((k) => [k, 0])); - let chunkCount = 0; + // Accumulate a bounded byte sample and run chardet once on the whole sample + // in result(). Running analyse() per-chunk and averaging corrupts results at + // multibyte chunk boundaries (a split sequence mis-detects each fragment). + const sample = []; + let sampleLength = 0; + const encoder = new TextEncoder(); const passThrough = (chunk) => { - const matches = analyse( - typeof chunk === "string" ? Buffer.from(chunk) : chunk, - ); - chunkCount++; - if (matches.length) { - for (const match of matches) { - if (match.name in charsets) { - charsets[match.name] += match.confidence; - } - } - } + const bytes = typeof chunk === "string" ? encoder.encode(chunk) : chunk; + // Keep only the bytes that still fit under the cap. subarray clamps the + // end index to the array length, so this both passes short chunks through + // whole and truncates the one chunk that crosses MAX_DETECTION_SAMPLE; + // once the cap is reached remaining is 0 and the slice is empty, so no + // further bytes are ever sampled. This single bound makes the cap the + // sole gate (no redundant length guard that a slice would mask). + const remaining = MAX_DETECTION_SAMPLE - sampleLength; + const slice = bytes.subarray(0, remaining); + sample.push(slice); + sampleLength += slice.length; }; const stream = createPassThroughStream(passThrough, streamOptions); stream.result = () => { - const divisor = chunkCount || 1; + // No bytes seen: signal "nothing to detect" rather than a phantom guess so + // callers can distinguish empty input from a low-confidence real result. + if (!sampleLength) { + return { + key: resultKey ?? "charset", + value: { charset: undefined, confidence: 0 }, + }; + } + const charsets = Object.fromEntries(charsetKeys.map((k) => [k, undefined])); + const matches = analyse(concatBytes(sample, sampleLength)); + for (const match of matches) { + const name = normaliseMatchName(match.name); + if (name in charsets) { + charsets[name] = Math.max(charsets[name] ?? 0, match.confidence); + } + } const values = Object.entries(charsets) .map(([charset, confidence]) => ({ charset, - confidence: confidence / divisor, + confidence: confidence ?? 0, })) .sort((a, b) => b.confidence - a.confidence); return { key: resultKey ?? "charset", value: values[0] }; diff --git a/packages/charset/encode.node.js b/packages/charset/encode.node.js index b167dba..6a12ed6 100644 --- a/packages/charset/encode.node.js +++ b/packages/charset/encode.node.js @@ -11,14 +11,21 @@ export const charsetEncodeStream = ({ charset } = {}, streamOptions = {}) => { const conv = iconv.getEncoder(charset); const transform = (chunk, enqueue) => { + // conv.write() always returns a Buffer (never nullish), so a plain + // .length check is enough here. const res = conv.write(chunk); - if (res?.length) { + if (res.length) { enqueue(res); } }; - const flush = () => { - // iconv-lite encoder.end() always returns undefined, so no flush needed - conv.end(); + const flush = (enqueue) => { + // Stateful encoders (e.g. UTF-7-IMAP) buffer multibyte content and emit + // their trailing shift-out sequence only from end(); mirror decode.node.js + // and enqueue that tail so no data is dropped. + const res = conv.end(); + if (res?.length) { + enqueue(res); + } }; return createTransformStream(transform, flush, streamOptions); }; diff --git a/packages/charset/encode.web.js b/packages/charset/encode.web.js index 21dc5c2..d3b7d0f 100644 --- a/packages/charset/encode.web.js +++ b/packages/charset/encode.web.js @@ -2,8 +2,21 @@ // SPDX-License-Identifier: MIT /* global TextEncoderStream */ +// NOTE: web vs node parity caveat. The browser TextEncoder genuinely only +// supports UTF-8, so the web encoder is UTF-8-only and throws for any other +// charset. This is intentionally asymmetric with charsetDecodeStream (web), +// which accepts every label TextDecoder supports (UTF-16, ISO-8859-1, ...), and +// with the node encoder, which supports the full iconv set. A detect->encode +// pipeline that selects a non-UTF-8 charset works under node but throws under +// web; callers targeting the browser must encode to UTF-8. export const charsetEncodeStream = ({ charset } = {}, _streamOptions = {}) => { - if (charset !== null && charset.toUpperCase() !== "UTF-8") { + // Default/null charset means UTF-8, matching the node implementation. Only a + // non-UTF-8 charset is rejected; calling with no charset must not throw. + if ( + charset !== null && + charset !== undefined && + String(charset).toUpperCase() !== "UTF-8" + ) { throw new Error( `charsetEncodeStream: Web only supports UTF-8 encoding, got "${charset}"`, ); diff --git a/packages/charset/index.test.js b/packages/charset/index.test.js index b8bf89e..67e0b17 100644 --- a/packages/charset/index.test.js +++ b/packages/charset/index.test.js @@ -13,6 +13,7 @@ import { streamToArray, streamToString, } from "@datastream/core"; +import iconv from "iconv-lite"; let variant = "unknown"; for (const execArgv of process.execArgv) { @@ -262,6 +263,25 @@ test(`${variant}: charsetDecodeStream should handle unsupported charset`, async deepStrictEqual(output, "test"); }); +// *** stateful-encoder flush: encoders such as UTF-7-IMAP buffer multibyte +// content and emit the trailing shift sequence ONLY from end(); the flush +// handler must enqueue conv.end()'s return value or that tail is lost. *** // +test(`${variant}: charsetEncodeStream should flush trailing bytes of a stateful encoder (UTF-7-IMAP)`, async (_t) => { + const original = "Hello 世界"; + const streams = [ + createReadableStream([original]), + charsetEncodeStream({ charset: "UTF-7-IMAP" }), + ]; + + const stream = pipejoin(streams); + const output = await streamToArray(stream); + const encoded = Buffer.concat(output.map((c) => Buffer.from(c))); + + // Round-trip through iconv to confirm the full multibyte content survived. + const decoded = iconv.decode(encoded, "UTF-7-IMAP"); + deepStrictEqual(decoded, original); +}); + test(`${variant}: charsetEncodeStream should handle ISO-8859-8-I charset`, async (_t) => { const input = ["test"]; const streams = [ @@ -324,6 +344,37 @@ test(`${variant}: charsetDetectStream should identify UTF-8 for multibyte input` ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); }); +// *** ascii-classification: pure ASCII text (the most common input) must NOT +// be misreported as a high-confidence ISO-8859-1; ASCII is UTF-8-compatible +// so it should fold into UTF-8 rather than being dropped. *** // +test(`${variant}: charsetDetectStream should classify pure ASCII text as UTF-8`, async (_t) => { + for (const text of ["Hello World", "Test content", "abc", "1234567890"]) { + const streams = [ + createReadableStream([Buffer.from(text)]), + charsetDetectStream(), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual( + value.charset, + "UTF-8", + `ASCII text ${JSON.stringify(text)} should classify as UTF-8, got ${value.charset}@${value.confidence}`, + ); + ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); + } +}); + +// *** empty-input sentinel: an empty stream ran detection on zero bytes; the +// result must signal "nothing to detect" rather than a phantom UTF-8 guess. *** // +test(`${variant}: charsetDetectStream should signal unknown for empty input`, async (_t) => { + const streams = [createReadableStream([]), charsetDetectStream()]; + await pipeline(streams); + const { key, value } = streams[1].result(); + strictEqual(key, "charset"); + strictEqual(value.charset, undefined); + strictEqual(value.confidence, 0); +}); + // *** charsetDetectStream concurrent isolation regression *** // test(`${variant}: charsetDetectStream instances should not share state`, async (_t) => { const input1 = [Buffer.from("Hello World")]; @@ -386,3 +437,767 @@ if (variant === "webstream") { } }); } + +// *** detect-per-chunk-analyse: detection must be accurate across multibyte +// chunk boundaries (buffer for detection, do not average per-chunk) *** // +test(`${variant}: charsetDetectStream should detect UTF-8 when a multibyte char is split across chunks`, async (_t) => { + // A 3-byte UTF-8 character split mid-sequence so the first chunk is a single + // leading byte. Per-chunk analysis mis-detects the fragments (chunk1 -> + // UTF-32LE, chunk2 -> Shift_JIS) and averaging flips the winner away from + // UTF-8; whole-sample analysis must still confidently report UTF-8. + const full = Buffer.from("テスト", "utf8"); + const chunk1 = full.subarray(0, 1); + const chunk2 = full.subarray(1); + + const streams = [ + createReadableStream([chunk1, chunk2]), + charsetDetectStream(), + ]; + + await pipeline(streams); + const { value } = streams[1].result(); + + strictEqual(value.charset, "UTF-8"); + ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); +}); + +// *** koi8r-key-typo: KOI8-R encoded Cyrillic must be detected as KOI8-R +// (the accumulator key must match chardet's output name) *** // +test(`${variant}: charsetDetectStream should detect KOI8-R Cyrillic text`, async (_t) => { + // "Привет, как дела? ..." encoded as KOI8-R (single-byte Cyrillic). + // Byte sequence produced by iconv-lite koi8-r encode of the Russian text. + const koi8rBytes = Buffer.from([ + 0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x2c, 0x20, 0xcb, 0xc1, 0xcb, 0x20, + 0xc4, 0xc5, 0xcc, 0xc1, 0x3f, 0x20, 0xfa, 0xd4, 0xcf, 0x20, 0xd4, 0xc5, + 0xd3, 0xd4, 0xcf, 0xd7, 0xd9, 0xca, 0x20, 0xd4, 0xc5, 0xcb, 0xd3, 0xd4, + 0x20, 0xce, 0xc1, 0x20, 0xd2, 0xd5, 0xd3, 0xd3, 0xcb, 0xcf, 0xcd, 0x20, + 0xd1, 0xda, 0xd9, 0xcb, 0xc5, 0x2e, + ]); + const streams = [createReadableStream([koi8rBytes]), charsetDetectStream()]; + + await pipeline(streams); + const { value } = streams[1].result(); + + strictEqual(value.charset, "KOI8-R"); + ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); +}); + +// *** web-specific fixes: import the web sources directly. A bare +// "@datastream/charset" import always resolves to the node build, so the +// *.web.js code paths are exercised by importing them by file URL. *** // +const decodeWeb = await import( + `file://${new URL("./decode.web.js", import.meta.url).pathname}` +); +const encodeWeb = await import( + `file://${new URL("./encode.web.js", import.meta.url).pathname}` +); +// detect.js is a single cross-platform source built for both node and web. To +// prove it is web-safe we import the source directly and run it with the +// node-only `Buffer` global removed, simulating a browser runtime. +const detectSource = await import( + `file://${new URL("./detect.js", import.meta.url).pathname}` +); + +const webStreamToString = async (stream, inputs) => { + const reader = stream.readable.getReader(); + const writer = stream.writable.getWriter(); + const collected = []; + const pump = (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + collected.push(value); + } + })(); + for (const input of inputs) { + await writer.write(input); + } + await writer.close(); + await pump; + return collected.join(""); +}; + +const webStreamToBytes = async (stream, inputs) => { + const reader = stream.readable.getReader(); + const writer = stream.writable.getWriter(); + const collected = []; + const pump = (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + collected.push(value); + } + })(); + for (const input of inputs) { + await writer.write(input); + } + await writer.close(); + await pump; + return Buffer.concat(collected.map((c) => Buffer.from(c))); +}; + +// *** web-default-charset-crash: no-arg / null charset must fall back to +// UTF-8 like node, not throw *** // +test(`${variant}: web charsetDecodeStream should default to UTF-8 with no charset`, async (_t) => { + const stream = decodeWeb.charsetDecodeStream(); + const output = await webStreamToString(stream, [Buffer.from("Test", "utf8")]); + strictEqual(output, "Test"); +}); + +test(`${variant}: web charsetDecodeStream should treat null charset as UTF-8`, async (_t) => { + const stream = decodeWeb.charsetDecodeStream({ charset: null }); + const output = await webStreamToString(stream, [ + Buffer.from("Hello", "utf8"), + ]); + strictEqual(output, "Hello"); +}); + +test(`${variant}: web charsetEncodeStream should default to UTF-8 with no charset`, async (_t) => { + const stream = encodeWeb.charsetEncodeStream(); + const output = await webStreamToBytes(stream, ["Test"]); + deepStrictEqual(output, Buffer.from("Test", "utf8")); +}); + +test(`${variant}: web charsetEncodeStream should treat null charset as UTF-8`, async (_t) => { + const stream = encodeWeb.charsetEncodeStream({ charset: null }); + const output = await webStreamToBytes(stream, ["Hello"]); + deepStrictEqual(output, Buffer.from("Hello", "utf8")); +}); + +// *** web-decode-allowlist-too-strict: ISO-8859-1 / ISO-8859-9 are accepted +// by TextDecoder and emitted by the detector, so decode must accept them *** // +test(`${variant}: web charsetDecodeStream should accept ISO-8859-1`, async (_t) => { + // 0xE9 is "é" in ISO-8859-1 (latin1). + const stream = decodeWeb.charsetDecodeStream({ charset: "ISO-8859-1" }); + const output = await webStreamToString(stream, [Buffer.from([0xe9])]); + strictEqual(output, "é"); +}); + +test(`${variant}: web charsetDecodeStream should accept ISO-8859-9`, async (_t) => { + const stream = decodeWeb.charsetDecodeStream({ charset: "ISO-8859-9" }); + const output = await webStreamToString(stream, [ + Buffer.from("abc", "latin1"), + ]); + strictEqual(output, "abc"); +}); + +test(`${variant}: web charsetDecodeStream should still reject genuinely unknown encodings`, async (_t) => { + try { + decodeWeb.charsetDecodeStream({ charset: "INVALID-CHARSET-999" }); + throw new Error("Expected error"); + } catch (e) { + ok( + e.message.includes("Unsupported web encoding"), + `unexpected error: ${e.message}`, + ); + } +}); + +// Run fn with the node-only `Buffer` global removed so any reference to it +// throws ReferenceError, simulating a browser runtime. createReadableStream / +// pipeline themselves do not depend on the Buffer global, so any +// "Buffer is not defined" comes from detect.js itself. +const withoutBuffer = async (fn) => { + const saved = globalThis.Buffer; + // Remove the global entirely (not just set undefined) so any reference to + // the bare `Buffer` identifier throws ReferenceError, matching a browser. + Reflect.deleteProperty(globalThis, "Buffer"); + try { + return await fn(); + } finally { + globalThis.Buffer = saved; + } +}; + +const driveDetect = async (inputs) => { + const streams = [ + createReadableStream(inputs), + detectSource.charsetDetectStream(), + ]; + await pipeline(streams); + return streams[1].result(); +}; + +// *** web-detect-buffer-crash: detect must not reference the node-only Buffer +// global in the web build. Exercise the source with BOTH a Uint8Array chunk +// and a string chunk (both pass through the byte-collection path that +// previously called Buffer.from / Buffer.concat) while Buffer is absent. *** // +test(`${variant}: web charsetDetectStream should detect ASCII without referencing Buffer`, async (_t) => { + const { key, value } = await withoutBuffer(() => + driveDetect([new TextEncoder().encode("Hello World")]), + ); + strictEqual(key, "charset"); + strictEqual(value.charset, "UTF-8"); + ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); +}); + +test(`${variant}: web charsetDetectStream should accept string chunks without Buffer`, async (_t) => { + const { value } = await withoutBuffer(() => driveDetect(["Test content"])); + strictEqual(value.charset, "UTF-8"); +}); + +test(`${variant}: web charsetDetectStream should signal unknown for empty input`, async (_t) => { + const { value } = await withoutBuffer(() => driveDetect([])); + strictEqual(value.charset, undefined); + strictEqual(value.confidence, 0); +}); + +// *** getSupportedEncoding via detect.js direct import: the function must be +// exported from the shared source so both node and web builds expose it. *** // +test(`${variant}: detect.js getSupportedEncoding should convert ISO-8859-8-I to ISO-8859-8`, (_t) => { + strictEqual(detectSource.getSupportedEncoding("ISO-8859-8-I"), "ISO-8859-8"); +}); + +test(`${variant}: detect.js getSupportedEncoding should pass through other charsets unchanged`, (_t) => { + strictEqual(detectSource.getSupportedEncoding("UTF-8"), "UTF-8"); +}); + +// *** charset key string mutations: each charset name in the allowlist must be +// correct so that chardet results are stored under the right key and the +// top-confidence charset is reported accurately. Mutating any name to "" means +// the chardet result is lost and a wrong (zero-confidence) charset wins. *** // + +test(`${variant}: charsetDetectStream should detect UTF-16BE`, async (_t) => { + // UTF-16BE BOM + "Hello World" + const bytes = Buffer.from([ + 0xfe, 0xff, 0x00, 0x48, 0x00, 0x65, 0x00, 0x6c, 0x00, 0x6c, 0x00, 0x6f, + 0x00, 0x20, 0x00, 0x57, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x6c, 0x00, 0x64, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-16BE"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect UTF-16LE`, async (_t) => { + // UTF-16LE BOM + "Hello World" + const bytes = Buffer.from([ + 0xff, 0xfe, 0x48, 0x00, 0x65, 0x00, 0x6c, 0x00, 0x6c, 0x00, 0x6f, 0x00, + 0x20, 0x00, 0x57, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x6c, 0x00, 0x64, 0x00, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-16LE"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect UTF-32BE`, async (_t) => { + // UTF-32BE encoding of "Hello World" + const bytes = Buffer.from([ + 0, 0, 0, 72, 0, 0, 0, 101, 0, 0, 0, 108, 0, 0, 0, 108, 0, 0, 0, 111, 0, 0, + 0, 32, 0, 0, 0, 87, 0, 0, 0, 111, 0, 0, 0, 114, 0, 0, 0, 108, 0, 0, 0, 100, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-32BE"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect UTF-32LE`, async (_t) => { + // UTF-32LE encoding of "Hello World" + const bytes = Buffer.from([ + 72, 0, 0, 0, 101, 0, 0, 0, 108, 0, 0, 0, 108, 0, 0, 0, 111, 0, 0, 0, 32, 0, + 0, 0, 87, 0, 0, 0, 111, 0, 0, 0, 114, 0, 0, 0, 108, 0, 0, 0, 100, 0, 0, 0, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-32LE"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect Shift_JIS`, async (_t) => { + // Shift_JIS: "\u3053\u308C\u306F\u30C6\u30B9\u30C8\u3067\u3059\u3002\u65E5\u672C\u8A9E" + const bytes = Buffer.from([ + 130, 177, 130, 234, 130, 205, 131, 101, 131, 88, 131, 103, 130, 197, 130, + 183, 129, 66, 147, 250, 150, 123, 140, 234, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "Shift_JIS"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect EUC-JP`, async (_t) => { + // EUC-JP: "\u3053\u308C\u306F\u30C6\u30B9\u30C8\u3067\u3059\u3002\u65E5\u672C\u8A9E" + const bytes = Buffer.from([ + 164, 179, 164, 236, 164, 207, 165, 198, 165, 185, 165, 200, 164, 199, 164, + 185, 161, 163, 198, 252, 203, 220, 184, 236, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "EUC-JP"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect EUC-KR`, async (_t) => { + // EUC-KR: "\uC548\uB155\uD558\uC138\uC694 \uD55C\uAD6D\uC5B4 \uD14D\uC2A4\uD2B8" + const bytes = Buffer.from([ + 190, 200, 179, 231, 199, 207, 188, 188, 191, 228, 32, 199, 209, 177, 185, + 190, 238, 32, 197, 216, 189, 186, 198, 174, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "EUC-KR"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect Big5`, async (_t) => { + // Big5: "\u9019\u662F\u6E2C\u8A66\u3002\u7E41\u9AD4\u4E2D\u6587\u6587\u672C" + const bytes = Buffer.from([ + 179, 111, 172, 79, 180, 250, 184, 213, 161, 67, 193, 99, 197, 233, 164, 164, + 164, 229, 164, 229, 165, 187, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "Big5"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect GB18030`, async (_t) => { + // GB18030: "\u8FD9\u662F\u6D4B\u8BD5\u3002\u7B80\u4F53\u4E2D\u6587\u6587\u672C" + const bytes = Buffer.from([ + 213, 226, 202, 199, 178, 226, 202, 212, 161, 163, 188, 242, 204, 229, 214, + 208, 206, 196, 206, 196, 177, 190, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "GB18030"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect ISO-2022-JP`, async (_t) => { + // ISO-2022-JP escape-sequence encoded Japanese + const bytes = Buffer.from([ + 0x1b, 0x24, 0x42, 0x46, 0x7c, 0x38, 0x6b, 0x4c, 0x68, 0x1b, 0x28, 0x42, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-2022-JP"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-2022-KR`, async (_t) => { + // ISO-2022-KR escape-sequence encoded Korean + const bytes = Buffer.from([ + 0x1b, 0x24, 0x29, 0x43, 0x0e, 0x4a, 0x49, 0x4e, 0x59, 0x0f, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-2022-KR"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-2022-CN`, async (_t) => { + // ISO-2022-CN escape-sequence encoded Chinese + const bytes = Buffer.from([ + 0x1b, 0x24, 0x29, 0x41, 0x0e, 0x32, 0x36, 0x33, 0x37, 0x0f, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-2022-CN"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-1`, async (_t) => { + // ISO-8859-1: "La s\u00E9r\u00E9nade de pain fran\u00E7ais \u00E9tait tr\u00E8s \u00E9l\u00E9gante" + const bytes = Buffer.from([ + 0x4c, 0x61, 0x20, 0x73, 0xe9, 0x72, 0xe9, 0x6e, 0x61, 0x64, 0x65, 0x20, + 0x64, 0x65, 0x20, 0x70, 0x61, 0x69, 0x6e, 0x20, 0x66, 0x72, 0x61, 0xee, + 0xe7, 0x61, 0x69, 0x73, 0x20, 0xe9, 0x74, 0xe0, 0x69, 0x74, 0x20, 0x74, + 0x72, 0xe8, 0x73, 0x20, 0xe9, 0x6c, 0xe9, 0x67, 0x61, 0x6e, 0x74, 0x65, + 0x20, 0x63, 0x61, 0x72, 0x20, 0x65, 0x6c, 0x6c, 0x65, 0x20, 0x65, 0x73, + 0x74, 0x20, 0x74, 0x72, 0xe8, 0x73, 0x20, 0x62, 0x6f, 0x6e, 0x6e, 0x65, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-1"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-2`, async (_t) => { + // ISO-8859-2: Polish "Polskie znaki: szcz\u0119\u015Bcie, \u017Ar\u00F3d\u0142o, ni\u017C, b\u0142\u0105d" + const bytes = Buffer.from([ + 80, 111, 108, 115, 107, 105, 101, 32, 122, 110, 97, 107, 105, 58, 32, 115, + 122, 99, 122, 234, 182, 99, 105, 101, 44, 32, 188, 114, 243, 100, 179, 111, + 44, 32, 110, 105, 191, 44, 32, 98, 179, 177, 100, 44, 32, 243, 119, 44, 32, + 179, 177, 107, 97, 44, 32, 112, 114, 122, 121, 115, 122, 179, 111, 182, 230, + 46, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-2"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-5`, async (_t) => { + // ISO-8859-5: Cyrillic "\u042D\u0442\u043E \u0442\u0435\u0441\u0442\u043E\u0432\u044B\u0439 \u0442\u0435\u043A\u0441\u0442 \u043D\u0430 \u0440\u0443\u0441\u0441\u043A\u043E\u043C" + const bytes = Buffer.from([ + 205, 226, 222, 32, 226, 213, 225, 226, 222, 210, 235, 217, 32, 226, 213, + 218, 225, 226, 32, 221, 208, 32, 224, 227, 225, 225, 218, 222, 220, 32, 239, + 215, 235, 218, 213, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-5"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-6`, async (_t) => { + // ISO-8859-6: Arabic long text + const bytes = Buffer.from([ + 231, 208, 199, 32, 230, 213, 32, 217, 209, 200, 234, 46, 32, 199, 228, 230, + 213, 32, 199, 228, 217, 209, 200, 234, 32, 228, 228, 199, 206, 202, 200, + 199, 209, 46, 32, 199, 228, 199, 206, 202, 200, 199, 209, 32, 229, 231, 229, + 32, 204, 207, 199, 235, 46, 32, 230, 213, 32, 199, 206, 202, 200, 199, 209, + 32, 217, 209, 200, 234, 32, 229, 215, 232, 228, 46, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-6"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-7`, async (_t) => { + // ISO-8859-7: Greek "\u0391\u03C5\u03C4\u03CC \u03B5\u03AF\u03BD\u03B1\u03B9 \u03B5\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03CC \u03BA\u03B5\u03AF\u03BC\u03B5\u03BD\u03BF" + const bytes = Buffer.from([ + 193, 245, 244, 252, 32, 229, 223, 237, 225, 233, 32, 229, 235, 235, 231, + 237, 233, 234, 252, 32, 234, 229, 223, 236, 229, 237, 239, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-7"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-8`, async (_t) => { + // ISO-8859-8: Hebrew "\u05D6\u05D4\u05D5 \u05D8\u05E7\u05E1\u05D8 \u05D1\u05D3\u05D9\u05E7\u05D4 \u05D1\u05E2\u05D1\u05E8\u05D9\u05EA" + const bytes = Buffer.from([ + 230, 228, 229, 32, 232, 247, 241, 232, 32, 225, 227, 233, 247, 228, 32, 225, + 242, 225, 248, 233, 250, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-8"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-9`, async (_t) => { + // ISO-8859-9: Turkish "Bu T\u00FCrk\u00E7e bir test metni G\u00FC\u015F\u0130\u00D6\u00C7\u015E\u0130" + const bytes = Buffer.from([ + 66, 117, 32, 84, 252, 114, 107, 231, 101, 32, 98, 105, 114, 32, 116, 101, + 115, 116, 32, 109, 101, 116, 110, 105, 32, 71, 252, 254, 221, 214, 199, 222, + 221, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-9"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect windows-1251`, async (_t) => { + // windows-1251: Cyrillic "\u041F\u0440\u0438\u0432\u0435\u0442 \u043C\u0438\u0440 \u042D\u0442\u043E \u0442\u0435\u0441\u0442\u043E\u0432\u044B\u0439 \u0442\u0435\u043A\u0441\u0442 \u043D\u0430 \u0440\u0443\u0441\u0441\u043A\u043E\u043C" + const bytes = Buffer.from([ + 207, 240, 232, 226, 229, 242, 32, 236, 232, 240, 32, 221, 242, 238, 32, 242, + 229, 241, 242, 238, 226, 251, 233, 32, 242, 229, 234, 241, 242, 32, 237, + 224, 32, 240, 243, 241, 241, 234, 238, 236, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1251"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect windows-1252`, async (_t) => { + // windows-1252 specific code points in 0x80-0x9F range (EUR, ellipsis, quotes, dashes) + const bytes = Buffer.from([ + 0x80, 0x85, 0x93, 0x94, 0x96, 0x97, 0x20, 0x48, 0x65, 0x6c, 0x6c, 0x6f, + 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x20, 0x54, 0x65, 0x73, 0x74, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1252"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect windows-1250`, async (_t) => { + // windows-1250: Polish "Polskie znaki: szcz\u0119\u015Bcie, \u017Ar\u00F3d\u0142o, ni\u017C, b\u0142\u0105d" + const bytes = Buffer.from([ + 80, 111, 108, 115, 107, 105, 101, 32, 122, 110, 97, 107, 105, 58, 32, 115, + 122, 99, 122, 234, 156, 99, 105, 101, 44, 32, 159, 114, 243, 100, 179, 111, + 44, 32, 110, 105, 191, 44, 32, 98, 179, 185, 100, 44, 32, 243, 119, 44, 32, + 179, 185, 107, 97, 44, 32, 112, 114, 122, 121, 115, 122, 179, 111, 156, 230, + 46, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1250"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect windows-1256`, async (_t) => { + // windows-1256: Arabic long text + const bytes = Buffer.from([ + 229, 208, 199, 32, 228, 213, 32, 199, 206, 202, 200, 199, 209, 32, 200, 199, + 225, 225, 219, 201, 32, 199, 225, 218, 209, 200, 237, 201, 46, 32, 199, 225, + 223, 202, 199, 200, 201, 32, 199, 225, 218, 209, 200, 237, 201, 32, 204, + 227, 237, 225, 201, 32, 230, 227, 218, 222, 207, 201, 46, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1256"); + ok(value.confidence > 0); +}); + +// *** detect-string-chunk: a string chunk must be encoded to bytes via +// TextEncoder so detection works correctly. Mutating typeof chunk === "string" +// to false causes the string to be passed raw to Uint8Array.set(), which +// silently writes zeros and detection returns garbage/undefined charset. *** // +test(`${variant}: charsetDetectStream should correctly detect charset from a string chunk`, async (_t) => { + // String chunk passed directly (not as Buffer). The passThrough function must + // encode it to bytes; if the typeof guard is removed the result is zeros. + const streams = [ + createReadableStream(["Hello World Test Content"]), + charsetDetectStream(), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual( + value.charset, + "UTF-8", + `string chunk should detect UTF-8, got ${value.charset}`, + ); + ok( + value.confidence > 0, + `confidence ${value.confidence} should be > 0 for string input`, + ); +}); + +// *** decode-valid-charset-preserved: when iconv.encodingExists(charset) is +// true the fallback to UTF-8 must NOT fire. Mutating the guard to if(true) +// would always reset charset to UTF-8, breaking decodes of other charsets. *** // +test(`${variant}: charsetDecodeStream should preserve non-UTF-8 charset (not fall back)`, async (_t) => { + // 0xE9 0xE0 0xE8 are "\u00E9\u00E0\u00E8" in ISO-8859-1; as UTF-8 these bytes are invalid + // and would be replaced with the Unicode replacement character U+FFFD. + const iso1bytes = Buffer.from([0xe9, 0xe0, 0xe8]); + const streams = [ + createReadableStream([iso1bytes]), + charsetDecodeStream({ charset: "ISO-8859-1" }), + ]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + // Correct ISO-8859-1 decode yields "\u00E9\u00E0\u00E8" + strictEqual(output, "\u00E9\u00E0\u00E8"); + // If charset were wrongly replaced with UTF-8, output would contain U+FFFD + strictEqual(output.includes("\uFFFD"), false); +}); + +// *** decode-transform-empty-guard: conv.write() returns "" for partial +// multi-byte sequences; if the if(res?.length) guard is removed, empty strings +// are enqueued as extra chunks. A test counting chunks catches the mutant. *** // +test(`${variant}: charsetDecodeStream should not emit empty chunks for partial multi-byte sequences`, async (_t) => { + // U+1F600 emoji is 4 bytes in UTF-8: F0 9F 98 80. + // Split into 4 x 1-byte chunks so the first three write() calls return "". + // With guard: only 1 non-empty chunk ("\uD83D\uDE00") is enqueued. + // Without guard (if true): 4 chunks (3 x "" + "\uD83D\uDE00") are enqueued. + const emojiBytes = Buffer.from("\uD83D\uDE00", "utf8"); + const byteChunks = [ + emojiBytes.subarray(0, 1), + emojiBytes.subarray(1, 2), + emojiBytes.subarray(2, 3), + emojiBytes.subarray(3, 4), + ]; + const streams = [ + createReadableStream(byteChunks), + charsetDecodeStream({ charset: "UTF-8" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // Exactly one chunk: the complete emoji string + strictEqual(output.length, 1); + strictEqual(output[0], "\uD83D\uDE00"); +}); + +// *** encode-transform-empty-guard: conv.write() returns a 0-length Buffer for +// empty string chunks; if the guard is removed, empty Buffers are enqueued. +// A test checking the exact chunk count catches this mutant. *** // +test(`${variant}: charsetEncodeStream should not emit empty chunks for empty string inputs`, async (_t) => { + // Mix of empty strings and real content. Each empty string encodes to a + // 0-length Buffer. With guard: only the 2 non-empty inputs produce chunks. + // Without guard (if true): 5 chunks (3 x empty Buffer + 2 real). + const streams = [ + createReadableStream(["", "Hello", "", "World", ""]), + charsetEncodeStream({ charset: "UTF-8" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output.length, 2); + deepStrictEqual(output[0], Buffer.from("Hello")); + deepStrictEqual(output[1], Buffer.from("World")); +}); + +// *** detect-sample-cap: the passThrough must stop accumulating after +// MAX_DETECTION_SAMPLE (65536) bytes so memory is bounded and detection uses +// only the representative prefix. Mutating the guard to if(false) removes the +// cap entirely. This test confirms the stream completes without error and the +// sample cap code path is exercised. *** // +test(`${variant}: charsetDetectStream should handle input at and beyond MAX_DETECTION_SAMPLE`, async (_t) => { + // Send exactly MAX_DETECTION_SAMPLE + 1 bytes of ASCII content. + // Real code: returns early after 65536 bytes; extra byte is not sampled. + const MAX = 64 * 1024; // 65536 + const chunk1 = Buffer.alloc(MAX, 65); // 65536 x "A" + const chunk2 = Buffer.from([65]); // one more "A" + const streams = [ + createReadableStream([chunk1, chunk2]), + charsetDetectStream(), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-8"); + ok(value.confidence > 0); +}); + +// *** slice-overflow: when a single chunk is larger than MAX_DETECTION_SAMPLE, +// the real code must truncate to exactly MAX bytes. Mutants that skip the +// truncation (if(false) conditional, MAX+sampleLength arithmetic, <=remaining +// comparison) all append the overflow bytes to the sample, which shifts +// chardet's answer from UTF-8 (ASCII prefix) to KOI8-R (Cyrillic overflow). *** // +test(`${variant}: charsetDetectStream should truncate a single oversized chunk to MAX_DETECTION_SAMPLE`, async (_t) => { + // Build a single chunk: first MAX bytes are pure ASCII (→ UTF-8 when capped), + // followed by 32 KB of KOI8-R Cyrillic bytes (→ KOI8-R when overflow leaks). + const MAX = 64 * 1024; + const koi8rPattern = Buffer.from([ + 0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x2c, 0x20, 0xcb, 0xc1, 0xcb, 0x20, + 0xc4, 0xc5, 0xcc, 0xc1, 0x3f, 0x20, 0xfa, 0xd4, 0xcf, 0x20, 0xd4, 0xc5, + 0xd3, 0xd4, 0xcf, 0xd7, 0xd9, 0xca, 0x20, 0xd4, 0xc5, 0xcb, 0xd3, 0xd4, + 0x20, 0xce, 0xc1, 0x20, 0xd2, 0xd5, 0xd3, 0xd3, 0xcb, 0xcf, 0xcd, 0x20, + 0xd1, 0xda, 0xd9, 0xcb, 0xc5, 0x2e, + ]); + const overflow = Buffer.alloc(32 * 1024); + for (let i = 0; i < overflow.length; i++) + overflow[i] = koi8rPattern[i % koi8rPattern.length]; + + const singleChunk = Buffer.concat([Buffer.alloc(MAX, 0x41), overflow]); + const streams = [createReadableStream([singleChunk]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + + // Real code caps at MAX bytes (pure ASCII → UTF-8 @ confidence 100). + // Any overflow mutant includes KOI8-R bytes and detects KOI8-R instead. + strictEqual( + value.charset, + "UTF-8", + `oversized chunk: expected UTF-8 but got ${value.charset}@${value.confidence}`, + ); + strictEqual(value.confidence, 100); +}); + +// *** charsets-allowlist-windows-1254: chardet can report "windows-1254" +// (Turkish encoding with c1 bytes). The charsetKeys array must contain the +// exact string "windows-1254" so the match is stored and wins. Mutating the +// key to "" means chardet's result is discarded and windows-1250 wins instead. *** // +test(`${variant}: charsetDetectStream should detect windows-1254`, async (_t) => { + // windows-1254 Turkish text with c1-range bytes (0x80-0x9F) that force the + // windows-1254 detector over ISO-8859-9. Bytes produced by: + // iconv.encode('Türkçe metin ...', 'windows-1254') plus c1 bytes. + const bytes = Buffer.from([ + 128, 156, 157, 158, 145, 146, 147, 148, 84, 252, 114, 107, 231, 101, 32, + 109, 101, 116, 105, 110, 32, 254, 105, 105, 114, 32, 107, 97, 108, 101, 109, + 32, 246, 240, 114, 101, 116, 109, 101, 110, 32, 103, 252, 122, 101, 108, 32, + 231, 111, 99, 117, 107, 32, 100, 252, 110, 121, 97, 32, 97, 105, 108, 101, + 128, 156, 157, 158, 145, 146, 147, 148, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1254"); + ok(value.confidence > 0); +}); + +// *** charsets-allowlist-filter: the "name in charsets" guard must filter +// chardet results to only the charsets listed in charsetKeys. Mutating the +// guard to "if(true)" lets unknown charset names (e.g. "windows-1255") bypass +// the filter and potentially win at high confidence, displacing the real winner +// from the charsetKeys allowlist. *** // +test(`${variant}: charsetDetectStream should filter chardet results to charsetKeys allowlist`, async (_t) => { + // windows-1255 Hebrew bytes with c1 bytes. chardet returns "windows-1255" @ + // confidence 71 as the top match. "windows-1255" is NOT in charsetKeys, so + // the real code discards it; GB18030 wins at 10 from the in-allowlist matches. + // With if(true) mutant, "windows-1255" leaks through and wins at 71. + const bytes = Buffer.from([ + 128, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 249, 236, 229, 237, + 32, 242, 229, 236, 237, 128, 131, 132, 133, 134, 135, 136, 137, 138, 139, + 140, 249, 236, 229, 237, 32, 242, 229, 236, 237, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + // Real code: windows-1255 discarded → GB18030 wins at confidence 10. + // Mutant (if true): windows-1255 leaks → wins at confidence 71. + strictEqual( + value.charset, + "GB18030", + `expected GB18030 (in-allowlist winner) but got ${value.charset}@${value.confidence}`, + ); +}); + +// *** remaining-arithmetic: "remaining = MAX - sampleLength" bounds how many +// bytes of each subsequent chunk are stored. Mutating to MAX + sampleLength +// makes remaining huge so the second chunk is never truncated, leaking +// post-cap bytes into the sample and shifting detection from UTF-8 to KOI8-R. +// Only observable with TWO chunks (when sampleLength === 0 both expressions +// give MAX, so the first chunk is handled identically). *** // +test(`${variant}: charsetDetectStream should correctly cap the sample across two chunks`, async (_t) => { + const MAX = 64 * 1024; + const koi8rPattern = Buffer.from([ + 0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x2c, 0x20, 0xcb, 0xc1, 0xcb, 0x20, + 0xc4, 0xc5, 0xcc, 0xc1, 0x3f, 0x20, 0xfa, 0xd4, 0xcf, 0x20, 0xd4, 0xc5, + 0xd3, 0xd4, 0xcf, 0xd7, 0xd9, 0xca, 0x20, 0xd4, 0xc5, 0xcb, 0xd3, 0xd4, + 0x20, 0xce, 0xc1, 0x20, 0xd2, 0xd5, 0xd3, 0xd3, 0xcb, 0xcf, 0xcd, 0x20, + 0xd1, 0xda, 0xd9, 0xcb, 0xc5, 0x2e, + ]); + const koi8r = Buffer.alloc(MAX / 2); + for (let i = 0; i < koi8r.length; i++) + koi8r[i] = koi8rPattern[i % koi8rPattern.length]; + + // Chunk 1 fills MAX/2 bytes (sampleLength = MAX/2 after it). + // Chunk 2 has MAX/2 bytes ASCII followed by MAX/2 bytes KOI8-R. + // Real: remaining = MAX - MAX/2 = MAX/2, so only first MAX/2 of chunk2 kept. + // Mutant: remaining = MAX + MAX/2 (huge), full chunk2 kept, KOI8-R leaks in. + const chunk1 = Buffer.alloc(MAX / 2, 0x41); + const chunk2 = Buffer.concat([Buffer.alloc(MAX / 2, 0x41), koi8r]); + const streams = [ + createReadableStream([chunk1, chunk2]), + charsetDetectStream(), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual( + value.charset, + "UTF-8", + `two-chunk cap: expected UTF-8 but got ${value.charset}@${value.confidence}`, + ); + strictEqual(value.confidence, 100); +}); diff --git a/packages/charset/package.json b/packages/charset/package.json index eb86556..943428a 100644 --- a/packages/charset/package.json +++ b/packages/charset/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/charset", - "version": "0.5.0", + "version": "0.6.1", "description": "Character encoding detection, decoding, and conversion streams", "type": "module", "engines": { @@ -87,7 +87,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -108,8 +109,8 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0", - "chardet": "2.1.1", - "iconv-lite": "0.7.2" + "@datastream/core": "0.6.1", + "chardet": "2.2.0", + "iconv-lite": "0.7.3" } } diff --git a/packages/compress/README.md b/packages/compress/README.md index 69d3f05..09433ff 100644 --- a/packages/compress/README.md +++ b/packages/compress/README.md @@ -1,6 +1,6 @@

<datastream> `compress`

- datastream logo + datastream logo

Compression streams for gzip, brotli, zstd, and deflate.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/compress/brotli.node.js b/packages/compress/brotli.node.js index 1f75985..9a3b3fa 100644 --- a/packages/compress/brotli.node.js +++ b/packages/compress/brotli.node.js @@ -6,43 +6,61 @@ import { createBrotliDecompress, } from "node:zlib"; +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const guardOutput = (stream, maxOutputSize, label) => { + let outputSize = 0; + const originalPush = stream.push.bind(stream); + stream.push = (chunk, encoding) => { + if (chunk !== null) { + outputSize += chunk.byteLength ?? Buffer.byteLength(chunk); + if (outputSize > maxOutputSize) { + stream.push = originalPush; + stream.destroy( + new Error( + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ), + ); + return false; + } + } + return originalPush(chunk, encoding); + }; + const restore = () => { + stream.push = originalPush; + }; + stream.on("close", restore); + stream.on("error", restore); +}; + // quality: 0 - 11 -export const brotliCompressStream = ({ quality } = {}, streamOptions = {}) => { - return createBrotliCompress({ +export const brotliCompressStream = (options = {}, streamOptions = {}) => { + const { quality, maxOutputSize } = options; + const stream = createBrotliCompress({ ...streamOptions, params: { [constants.BROTLI_PARAM_QUALITY]: quality ?? constants.BROTLI_DEFAULT_QUALITY, }, }); + if (maxOutputSize !== null && maxOutputSize !== undefined) { + guardOutput(stream, maxOutputSize, "Compression"); + } + return stream; }; export const brotliDecompressStream = (options = {}, streamOptions = {}) => { - const { maxOutputSize, ...params } = options; - const zlibOptions = Object.keys(params).length - ? { ...streamOptions, params } - : streamOptions; + const { maxOutputSize, params } = options; + const zlibOptions = params ? { ...streamOptions, params } : streamOptions; const stream = createBrotliDecompress(zlibOptions); - if (maxOutputSize != null) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + if (limit !== undefined) { + guardOutput(stream, limit, "Decompression"); } return stream; }; diff --git a/packages/compress/brotli.web.js b/packages/compress/brotli.web.js index 16f17c5..54e917a 100644 --- a/packages/compress/brotli.web.js +++ b/packages/compress/brotli.web.js @@ -8,54 +8,98 @@ import { createTransformStream } from "@datastream/core"; import brotliPromise from "brotli-wasm"; // Import the default export -const { CompressStream, DecompressStream, BrotliStreamResult } = +const { CompressStream, DecompressStream, BrotliStreamResultCode } = await brotliPromise; // Import is async in browsers due to wasm requirements! +// Fixed-size output buffer; the streaming loop drains NeedsMoreOutput so any +// chunk/output size is handled correctly. +const OUTPUT_SIZE = 16_384; // 16KB + +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const textEncoder = new TextEncoder(); +const toBytes = (chunk) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk; + // https://github.com/httptoolkit/brotli-wasm/issues/14 export const brotliCompressStream = (options = {}, streamOptions = {}) => { - const { quality } = options; + const { quality, maxOutputSize } = options; const engine = new CompressStream(quality ?? 11); + let outputSize = 0; + const guard = (buf, enqueue) => { + if (maxOutputSize != null) { + outputSize += buf.byteLength; + if (outputSize > maxOutputSize) { + throw new Error( + `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ); + } + } + enqueue(buf); + }; const transform = (chunk, enqueue) => { - enqueue(engine.compress(chunk)); + const input = toBytes(chunk); + let inputOffset = 0; + let code; + do { + const result = engine.compress(input.slice(inputOffset), OUTPUT_SIZE); + guard(result.buf, enqueue); + inputOffset += result.input_offset; + code = result.code; + } while (code === BrotliStreamResultCode.NeedsMoreOutput); }; const flush = (enqueue) => { - if (engine.result() === BrotliStreamResult.NeedsMoreInput) { - enqueue(engine.compress(undefined, 100)); - } + let code; + do { + const result = engine.compress(undefined, OUTPUT_SIZE); + guard(result.buf, enqueue); + code = result.code; + } while (code === BrotliStreamResultCode.NeedsMoreOutput); }; return createTransformStream(transform, flush, streamOptions); }; export const brotliDecompressStream = (options = {}, streamOptions = {}) => { const { maxOutputSize } = options; + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); const engine = new DecompressStream(); let outputSize = 0; const transform = (chunk, enqueue) => { - const result = engine.decompress(chunk); - if (maxOutputSize != null) { - outputSize += result.byteLength; - if (outputSize > maxOutputSize) { - throw new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ); - } - } - enqueue(result); - }; - const flush = (enqueue) => { - if (engine.result() === BrotliStreamResult.NeedsMoreInput) { - const result = engine.decompress(undefined, 100); - if (maxOutputSize != null) { - outputSize += result.byteLength; - if (outputSize > maxOutputSize) { + const input = toBytes(chunk); + let inputOffset = 0; + let code; + do { + const result = engine.decompress(input.slice(inputOffset), OUTPUT_SIZE); + if (limit !== undefined) { + outputSize += result.buf.byteLength; + if (outputSize > limit) { throw new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, + `Decompression output exceeds maxOutputSize (${limit} bytes)`, ); } } - enqueue(result); + enqueue(result.buf); + inputOffset += result.input_offset; + code = result.code; + } while (code === BrotliStreamResultCode.NeedsMoreOutput); + // A complete brotli stream (ResultSuccess) followed by leftover bytes in + // the same chunk means trailing/garbage data; strict decoders reject it + // rather than silently dropping it. + if ( + code === BrotliStreamResultCode.ResultSuccess && + inputOffset < input.byteLength + ) { + throw new Error( + "Decompression has trailing bytes after end of brotli stream", + ); } }; - return createTransformStream(transform, flush, streamOptions); + return createTransformStream(transform, undefined, streamOptions); }; export default { diff --git a/packages/compress/deflate.node.js b/packages/compress/deflate.node.js index ff80036..9112b4f 100644 --- a/packages/compress/deflate.node.js +++ b/packages/compress/deflate.node.js @@ -2,58 +2,54 @@ // SPDX-License-Identifier: MIT import { createDeflate, createInflate } from "node:zlib"; +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const guardOutput = (stream, maxOutputSize, label) => { + let outputSize = 0; + const originalPush = stream.push.bind(stream); + stream.push = (chunk, encoding) => { + if (chunk !== null) { + outputSize += chunk.byteLength ?? Buffer.byteLength(chunk); + if (outputSize > maxOutputSize) { + stream.push = originalPush; + stream.destroy( + new Error( + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ), + ); + return false; + } + } + return originalPush(chunk, encoding); + }; + const restore = () => { + stream.push = originalPush; + }; + stream.on("close", restore); + stream.on("error", restore); +}; + // quality -1 - 9 -export const deflateCompressStream = (options = {}, _streamOptions = {}) => { - const { quality, maxOutputSize, ...rest } = options; - const stream = createDeflate({ ...rest, level: rest.level ?? quality }); +export const deflateCompressStream = (options = {}, streamOptions = {}) => { + const { quality, maxOutputSize, level } = options; + const stream = createDeflate({ ...streamOptions, level: level ?? quality }); if (maxOutputSize !== null && maxOutputSize !== undefined) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + guardOutput(stream, maxOutputSize, "Compression"); } return stream; }; export const deflateDecompressStream = (options = {}, streamOptions = {}) => { const { maxOutputSize } = options; const stream = createInflate(streamOptions); - if (maxOutputSize != null) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + if (limit !== undefined) { + guardOutput(stream, limit, "Decompression"); } return stream; }; diff --git a/packages/compress/deflate.web.js b/packages/compress/deflate.web.js index fdc9a80..c5d0eff 100644 --- a/packages/compress/deflate.web.js +++ b/packages/compress/deflate.web.js @@ -5,60 +5,119 @@ // - https://caniuse.com/?search=CompressionStream // - not supported on firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1586639 // - not supported in safari +import { makeOptions } from "@datastream/core"; -export const deflateCompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize } = options; - const compressor = new CompressionStream("deflate"); - if (maxOutputSize != null) { - let outputSize = 0; - const transformer = { +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const textEncoder = new TextEncoder(); +// The WHATWG CompressionStream/DecompressionStream require each chunk to be a +// BufferSource; strings throw a TypeError in spec-compliant browsers. Node's +// implementation is lenient, so convert here to match Node + web brotli. +const toBytes = (chunk) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk; + +// Input stage: convert string chunks to bytes (BufferSource) and honor an +// AbortSignal. The native CompressionStream/DecompressionStream accept neither a +// signal nor string chunks, so this stage provides both (parity with the Node +// build and web brotli). highWaterMark/chunkSize are threaded via makeOptions. +const inputStage = (streamOptions) => { + const { signal } = streamOptions ?? {}; + const { writableStrategy, readableStrategy } = makeOptions(streamOptions); + let onAbort; + const abortError = () => + signal.reason ?? new DOMException("Aborted", "AbortError"); + return new TransformStream( + { + start(controller) { + if (signal) { + if (signal.aborted) { + controller.error(abortError()); + return; + } + onAbort = () => controller.error(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + } + }, transform(chunk, controller) { - outputSize += chunk.byteLength; - if (outputSize > maxOutputSize) { - controller.error( - new Error( - `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return; + controller.enqueue(toBytes(chunk)); + }, + flush() { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; } - controller.enqueue(chunk); }, - }; - const limiter = new TransformStream(transformer); - return { - readable: compressor.readable.pipeThrough(limiter), - writable: compressor.writable, - }; - } - return compressor; + }, + writableStrategy, + readableStrategy, + ); }; -export const deflateDecompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize } = options; - const decompressor = new DecompressionStream("deflate"); - if (maxOutputSize != null) { - let outputSize = 0; - const transformer = { - transform(chunk, controller) { + +// Output stage: enforce maxOutputSize and keep honoring the AbortSignal for the +// whole stream lifetime (a mid-flight abort errors this terminal readable). +const outputStage = (maxOutputSize, label, streamOptions) => { + const { signal } = streamOptions ?? {}; + let onAbort; + let outputSize = 0; + const abortError = () => + signal.reason ?? new DOMException("Aborted", "AbortError"); + return new TransformStream({ + start(controller) { + if (signal) { + if (signal.aborted) { + controller.error(abortError()); + return; + } + onAbort = () => controller.error(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + } + }, + transform(chunk, controller) { + if (maxOutputSize !== null && maxOutputSize !== undefined) { outputSize += chunk.byteLength; if (outputSize > maxOutputSize) { controller.error( new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, ), ); return; } - controller.enqueue(chunk); - }, - }; - const limiter = new TransformStream(transformer); - return { - readable: decompressor.readable.pipeThrough(limiter), - writable: decompressor.writable, - }; - } - return decompressor; + } + controller.enqueue(chunk); + }, + flush() { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }, + }); +}; + +const wrap = (compressor, maxOutputSize, streamOptions, label) => { + const input = inputStage(streamOptions); + const output = outputStage(maxOutputSize, label, streamOptions); + const readable = input.readable.pipeThrough(compressor).pipeThrough(output); + return { readable, writable: input.writable }; +}; + +export const deflateCompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize } = options; + const compressor = new CompressionStream("deflate"); + return wrap(compressor, maxOutputSize, streamOptions, "Compression"); +}; +export const deflateDecompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize } = options; + const decompressor = new DecompressionStream("deflate"); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + return wrap(decompressor, limit, streamOptions, "Decompression"); }; export default { diff --git a/packages/compress/gzip.node.js b/packages/compress/gzip.node.js index c0a9255..68a8892 100644 --- a/packages/compress/gzip.node.js +++ b/packages/compress/gzip.node.js @@ -2,58 +2,54 @@ // SPDX-License-Identifier: MIT import { createGunzip, createGzip } from "node:zlib"; +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const guardOutput = (stream, maxOutputSize, label) => { + let outputSize = 0; + const originalPush = stream.push.bind(stream); + stream.push = (chunk, encoding) => { + if (chunk !== null) { + outputSize += chunk.byteLength ?? Buffer.byteLength(chunk); + if (outputSize > maxOutputSize) { + stream.push = originalPush; + stream.destroy( + new Error( + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ), + ); + return false; + } + } + return originalPush(chunk, encoding); + }; + const restore = () => { + stream.push = originalPush; + }; + stream.on("close", restore); + stream.on("error", restore); +}; + // quality -1 - 9 export const gzipCompressStream = (options = {}, streamOptions = {}) => { const { quality, maxOutputSize } = options; const stream = createGzip({ ...streamOptions, level: quality }); if (maxOutputSize !== null && maxOutputSize !== undefined) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + guardOutput(stream, maxOutputSize, "Compression"); } return stream; }; export const gzipDecompressStream = (options = {}, streamOptions = {}) => { const { maxOutputSize } = options; const stream = createGunzip(streamOptions); - if (maxOutputSize != null) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + if (limit !== undefined) { + guardOutput(stream, limit, "Decompression"); } return stream; }; diff --git a/packages/compress/gzip.web.js b/packages/compress/gzip.web.js index 7776ee1..465688a 100644 --- a/packages/compress/gzip.web.js +++ b/packages/compress/gzip.web.js @@ -5,60 +5,119 @@ // - https://caniuse.com/?search=CompressionStream // - not supported on firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1586639 // - not supported in safari +import { makeOptions } from "@datastream/core"; -export const gzipCompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize } = options; - const compressor = new CompressionStream("gzip"); - if (maxOutputSize != null) { - let outputSize = 0; - const transformer = { +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const textEncoder = new TextEncoder(); +// The WHATWG CompressionStream/DecompressionStream require each chunk to be a +// BufferSource; strings throw a TypeError in spec-compliant browsers. Node's +// implementation is lenient, so convert here to match Node + web brotli. +const toBytes = (chunk) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk; + +// Input stage: convert string chunks to bytes (BufferSource) and honor an +// AbortSignal. The native CompressionStream/DecompressionStream accept neither a +// signal nor string chunks, so this stage provides both (parity with the Node +// build and web brotli). highWaterMark/chunkSize are threaded via makeOptions. +const inputStage = (streamOptions) => { + const { signal } = streamOptions ?? {}; + const { writableStrategy, readableStrategy } = makeOptions(streamOptions); + let onAbort; + const abortError = () => + signal.reason ?? new DOMException("Aborted", "AbortError"); + return new TransformStream( + { + start(controller) { + if (signal) { + if (signal.aborted) { + controller.error(abortError()); + return; + } + onAbort = () => controller.error(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + } + }, transform(chunk, controller) { - outputSize += chunk.byteLength; - if (outputSize > maxOutputSize) { - controller.error( - new Error( - `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return; + controller.enqueue(toBytes(chunk)); + }, + flush() { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; } - controller.enqueue(chunk); }, - }; - const limiter = new TransformStream(transformer); - return { - readable: compressor.readable.pipeThrough(limiter), - writable: compressor.writable, - }; - } - return compressor; + }, + writableStrategy, + readableStrategy, + ); }; -export const gzipDecompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize } = options; - const decompressor = new DecompressionStream("gzip"); - if (maxOutputSize != null) { - let outputSize = 0; - const transformer = { - transform(chunk, controller) { + +// Output stage: enforce maxOutputSize and keep honoring the AbortSignal for the +// whole stream lifetime (a mid-flight abort errors this terminal readable). +const outputStage = (maxOutputSize, label, streamOptions) => { + const { signal } = streamOptions ?? {}; + let onAbort; + let outputSize = 0; + const abortError = () => + signal.reason ?? new DOMException("Aborted", "AbortError"); + return new TransformStream({ + start(controller) { + if (signal) { + if (signal.aborted) { + controller.error(abortError()); + return; + } + onAbort = () => controller.error(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + } + }, + transform(chunk, controller) { + if (maxOutputSize !== null && maxOutputSize !== undefined) { outputSize += chunk.byteLength; if (outputSize > maxOutputSize) { controller.error( new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, ), ); return; } - controller.enqueue(chunk); - }, - }; - const limiter = new TransformStream(transformer); - return { - readable: decompressor.readable.pipeThrough(limiter), - writable: decompressor.writable, - }; - } - return decompressor; + } + controller.enqueue(chunk); + }, + flush() { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }, + }); +}; + +const wrap = (compressor, maxOutputSize, streamOptions, label) => { + const input = inputStage(streamOptions); + const output = outputStage(maxOutputSize, label, streamOptions); + const readable = input.readable.pipeThrough(compressor).pipeThrough(output); + return { readable, writable: input.writable }; +}; + +export const gzipCompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize } = options; + const compressor = new CompressionStream("gzip"); + return wrap(compressor, maxOutputSize, streamOptions, "Compression"); +}; +export const gzipDecompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize } = options; + const decompressor = new DecompressionStream("gzip"); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + return wrap(decompressor, limit, streamOptions, "Decompression"); }; export default { diff --git a/packages/compress/index.d.ts b/packages/compress/index.d.ts index ebe4c82..d0aa8d2 100644 --- a/packages/compress/index.d.ts +++ b/packages/compress/index.d.ts @@ -13,10 +13,6 @@ export { gzipCompressStream, gzipDecompressStream, } from "@datastream/compress/gzip"; -export { - protobufDeserializeStream, - protobufSerializeStream, -} from "@datastream/compress/protobuf"; export { zstdCompressStream, zstdDecompressStream, diff --git a/packages/compress/index.js b/packages/compress/index.js index 9712e8b..c7b767e 100644 --- a/packages/compress/index.js +++ b/packages/compress/index.js @@ -3,5 +3,4 @@ export * from "@datastream/compress/brotli"; export * from "@datastream/compress/deflate"; export * from "@datastream/compress/gzip"; -export * from "@datastream/compress/protobuf"; export * from "@datastream/compress/zstd"; diff --git a/packages/compress/index.test.js b/packages/compress/index.test.js index 94fa665..4211218 100644 --- a/packages/compress/index.test.js +++ b/packages/compress/index.test.js @@ -1,9 +1,14 @@ import { ok, strictEqual } from "node:assert"; +import { realpathSync } from "node:fs"; +import { register } from "node:module"; import test from "node:test"; +import { pathToFileURL } from "node:url"; import { brotliCompressSync, + brotliDecompressSync, deflateSync, gzipSync, + constants as zlibConstants, zstdCompressSync, zstdDecompressSync, } from "node:zlib"; @@ -23,7 +28,6 @@ import { createReadableStream, pipejoin, pipeline, - streamToArray, streamToBuffer, streamToString, } from "@datastream/core"; @@ -158,6 +162,18 @@ if (variant === "node") { strictEqual(output, compressibleBody); }); + // A default decompression ceiling now applies; `maxOutputSize: null` opts + // out of any limit (unbounded), so normal payloads still round-trip. + test(`${variant}: gzipDecompressStream maxOutputSize:null disables the limit`, async (_t) => { + const input = gzipSync(compressibleBody); + const streams = [ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: null }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, compressibleBody); + }); + // *** maxOutputSize within limit (covers normal-path push) *** // test(`${variant}: gzipDecompressStream should pass through within maxOutputSize`, async (_t) => { const input = gzipSync(compressibleBody); @@ -189,6 +205,20 @@ if (variant === "node") { strictEqual(output, compressibleBody); }); + // Arbitrary keys on the first (options) argument must NOT be promoted into + // zlib Brotli `params` (the old behavior threw "chunkSize is not a valid + // Brotli parameter"). Only an explicit `params` field is forwarded; real + // stream options go on the second argument. + test(`${variant}: brotliDecompressStream should not promote first-arg keys to Brotli params`, async (_t) => { + const input = brotliCompressSync(compressibleBody); + const streams = [ + createReadableStream(input), + brotliDecompressStream({ chunkSize: 1024 }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, compressibleBody); + }); + // *** zstd decompress maxOutputSize *** // test(`${variant}: zstdDecompressStream should enforce maxOutputSize`, async (_t) => { const input = zstdCompressSync(compressibleBody); @@ -214,6 +244,26 @@ if (variant === "node") { strictEqual(output, compressibleBody); }); + // Covers `maxOutputSize === null ? void 0 :` true-branch in zstdDecompressStream. + test(`${variant}: zstdDecompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const input = zstdCompressSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + zstdDecompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + // guardOutput Buffer.byteLength(chunk) fallback for zstd (node-only): push a + // string directly into the guarded stream so .byteLength is undefined. + test(`${variant}: zstdDecompressStream guardOutput sizes string chunks via Buffer.byteLength`, (_t) => { + const stream = zstdDecompressStream({ maxOutputSize: 1024 }); + ok(stream.push("abc") === true); + stream.destroy(); + }); + // *** compress maxOutputSize *** // test(`${variant}: gzipCompressStream should enforce maxOutputSize`, async (_t) => { const input = compressibleBody; @@ -262,48 +312,47 @@ if (variant === "node") { const output = await streamToString(pipejoin(streams)); strictEqual(output, deflateSync(compressibleBody).toString()); }); -} -// *** protobuf *** // -let protobuf; -try { - protobuf = (await import("protobufjs")).default; -} catch { - // protobufjs not installed -} + // deflateCompressStream reads quality/level/maxOutputSize from the first + // argument and forwards real stream options from the second argument + // (matching gzipCompressStream), rather than spreading the first arg. + test(`${variant}: deflateCompressStream should honor quality and second-arg stream options`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + deflateCompressStream({ quality: 9 }, { chunkSize: 1024 }), + deflateDecompressStream(), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, compressibleBody); + }); -if (protobuf) { - const { protobufSerializeStream, protobufDeserializeStream } = await import( - "@datastream/compress/protobuf" - ); + test(`${variant}: brotliCompressStream should enforce maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: 5 }), + ]; + try { + await pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); - const TestType = new protobuf.Type("TestMessage") - .add(new protobuf.Field("name", 1, "string")) - .add(new protobuf.Field("value", 2, "int32")); - new protobuf.Root().define("test").add(TestType); - - test(`${variant}: protobuf roundtrip serialize/deserialize`, async (_t) => { - const input = [ - { name: "a", value: 1 }, - { name: "b", value: 2 }, - ]; - const serialize = protobufSerializeStream({ Type: TestType }); - const deserialize = protobufDeserializeStream({ Type: TestType }); - const streams = [createReadableStream(input), serialize, deserialize]; - const output = await streamToArray(pipejoin(streams)); - strictEqual(output.length, 2); - strictEqual(output[0].name, "a"); - strictEqual(output[1].name, "b"); - }); - - test(`${variant}: protobufDeserializeStream should enforce maxOutputSize`, async (_t) => { - const input = [{ name: "long-name-here", value: 12345 }]; - const serialize = protobufSerializeStream({ Type: TestType }); - const deserialize = protobufDeserializeStream({ - Type: TestType, - maxOutputSize: 5, - }); - const streams = [createReadableStream(input), serialize, deserialize]; + test(`${variant}: brotliCompressStream should pass through within maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: 1024 * 1024 }), + ]; + const output = await streamToBuffer(pipejoin(streams)); + strictEqual(brotliDecompressSync(output).toString(), compressibleBody); + }); + + test(`${variant}: zstdCompressStream should enforce maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + zstdCompressStream({ maxOutputSize: 5 }), + ]; try { await pipeline(streams); throw new Error("Should have thrown"); @@ -312,19 +361,661 @@ if (protobuf) { } }); - test(`${variant}: protobufDeserializeStream should pass through within maxOutputSize`, async (_t) => { - const input = [{ name: "a", value: 1 }]; - const serialize = protobufSerializeStream({ Type: TestType }); - const deserialize = protobufDeserializeStream({ - Type: TestType, - maxOutputSize: 1024, - }); - const streams = [createReadableStream(input), serialize, deserialize]; - const output = await streamToArray(pipejoin(streams)); - strictEqual(output[0].name, "a"); + test(`${variant}: zstdCompressStream should pass through within maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + zstdCompressStream({ maxOutputSize: 1024 * 1024 }), + ]; + const output = await streamToBuffer(pipejoin(streams)); + strictEqual(zstdDecompressSync(output).toString(), compressibleBody); }); } +// *** quality / level parameter routing *** // +if (variant === "node") { + // brotliCompressStream: quality=0 vs quality=11 produce different sizes; + // ensures params object is forwarded (ObjectLiteral survivor). + test(`${variant}: brotliCompressStream quality:0 differs from quality:11`, async (_t) => { + const out0 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream({ quality: 0 }), + ]), + ); + const out11 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream({ quality: 11 }), + ]), + ); + ok( + out0.byteLength !== out11.byteLength, + "different quality => different size", + ); + strictEqual(brotliDecompressSync(out0).toString(), compressibleBody); + strictEqual(brotliDecompressSync(out11).toString(), compressibleBody); + }); + + // default quality matches BROTLI_DEFAULT_QUALITY constant + // (covers quality ?? BROTLI_DEFAULT_QUALITY LogicalOperator survivor). + test(`${variant}: brotliCompressStream default quality equals explicit BROTLI_DEFAULT_QUALITY`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const outDef = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream(), + ]), + ); + const outExp = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream({ quality: zlibConst.BROTLI_DEFAULT_QUALITY }), + ]), + ); + strictEqual(outDef.toString("hex"), outExp.toString("hex")); + }); + + // gzipCompressStream: quality maps to zlib level; levels 1 and 9 differ. + test(`${variant}: gzipCompressStream quality:1 and quality:9 both round-trip`, async (_t) => { + const out1 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + gzipCompressStream({ quality: 1 }), + ]), + ); + const out9 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + gzipCompressStream({ quality: 9 }), + ]), + ); + ok(out1.byteLength !== out9.byteLength, "quality 1 vs 9 => different size"); + strictEqual( + await streamToString( + pipejoin([createReadableStream(out1), gzipDecompressStream()]), + ), + compressibleBody, + ); + strictEqual( + await streamToString( + pipejoin([createReadableStream(out9), gzipDecompressStream()]), + ), + compressibleBody, + ); + }); + + // deflateCompressStream: `level` takes precedence over `quality` + // (covers `level ?? quality` LogicalOperator survivor). + test(`${variant}: deflateCompressStream level overrides quality`, async (_t) => { + const compressed = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + deflateCompressStream({ level: 9, quality: 1 }), + ]), + ); + const output = await streamToString( + pipejoin([createReadableStream(compressed), deflateDecompressStream()]), + ); + strictEqual(output, compressibleBody); + }); + + // deflateCompressStream with quality:9 must match deflateSync at level:9. + // `level && quality` mutation: when level is undefined, `undefined && quality` + // = `undefined` → quality ignored → output would match default level, not 9. + // Also catches `createDeflate({})` mutation (drops level entirely). + test(`${variant}: deflateCompressStream quality:9 output matches deflateSync level:9`, async (_t) => { + const { deflateSync: defSync } = await import("node:zlib"); + const output = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + deflateCompressStream({ quality: 9 }), + ]), + ); + strictEqual( + output.toString("hex"), + defSync(compressibleBody, { level: 9 }).toString("hex"), + ); + }); + + // deflateCompressStream with level:1 must match deflateSync at level:1. + test(`${variant}: deflateCompressStream level:1 output matches deflateSync level:1`, async (_t) => { + const { deflateSync: defSync } = await import("node:zlib"); + const output = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + deflateCompressStream({ level: 1 }), + ]), + ); + strictEqual( + output.toString("hex"), + defSync(compressibleBody, { level: 1 }).toString("hex"), + ); + }); + + // zstdCompressStream: different quality levels both round-trip. + test(`${variant}: zstdCompressStream quality:1 and quality:19 both round-trip`, async (_t) => { + const out1 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + zstdCompressStream({ quality: 1 }), + ]), + ); + const out19 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + zstdCompressStream({ quality: 19 }), + ]), + ); + strictEqual(zstdDecompressSync(out1).toString(), compressibleBody); + strictEqual(zstdDecompressSync(out19).toString(), compressibleBody); + }); + + // zstdCompressStream with explicit params (covers `params ?? { ... }` ObjectLiteral survivor). + test(`${variant}: zstdCompressStream with explicit params round-trips`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const out = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + zstdCompressStream({ + params: { [zlibConst.ZSTD_c_compressionLevel]: 3 }, + }), + ]), + ); + strictEqual(zstdDecompressSync(out).toString(), compressibleBody); + }); + + // zstdDecompressStream with explicit params + // (covers `params ? {..., params} : streamOptions` ConditionalExpression survivor). + test(`${variant}: zstdDecompressStream with explicit params round-trips`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const input = zstdCompressSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + zstdDecompressStream({ + params: { [zlibConst.ZSTD_d_windowLogMax]: 27 }, + }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + // brotliDecompressStream with params option + // (covers `params ? {..., params} : streamOptions` ConditionalExpression survivor). + test(`${variant}: brotliDecompressStream with params option round-trips`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const input = brotliCompressSync(compressibleBody); + // BROTLI_PARAM_MODE is a valid decompress-time param (mode 0 = generic) + const output = await streamToString( + pipejoin([ + createReadableStream(input), + brotliDecompressStream({ + params: { [zlibConst.BROTLI_PARAM_MODE]: 0 }, + }), + ]), + ); + strictEqual(output, compressibleBody); + }); +} + +// *** maxOutputSize exact boundary (> vs >=) *** // +// Payload whose decompressed size exactly equals maxOutputSize must PASS. +// `outputSize > maxOutputSize` allows equal; `>=` would reject it. +if (variant === "node") { + test(`${variant}: gzipDecompressStream exact-boundary maxOutputSize should NOT throw`, async (_t) => { + const input = gzipSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: exactSize }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + test(`${variant}: deflateDecompressStream exact-boundary maxOutputSize should NOT throw`, async (_t) => { + const input = deflateSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + deflateDecompressStream({ maxOutputSize: exactSize }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + test(`${variant}: brotliDecompressStream exact-boundary maxOutputSize should NOT throw`, async (_t) => { + const input = brotliCompressSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + brotliDecompressStream({ maxOutputSize: exactSize }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + test(`${variant}: zstdDecompressStream exact-boundary maxOutputSize should NOT throw`, async (_t) => { + const input = zstdCompressSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + zstdDecompressStream({ maxOutputSize: exactSize }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + // One byte under the limit MUST throw. + test(`${variant}: gzipDecompressStream one-byte-under maxOutputSize should throw`, async (_t) => { + const input = gzipSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: exactSize - 1 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize"), e.message); + } + }); +} + +// *** error message label strings (StringLiteral survivors) *** // +// Error message must contain "Compression"/"Decompression" (not ""). +if (variant === "node") { + test(`${variant}: gzipDecompressStream error message contains "Decompression"`, async (_t) => { + const input = gzipSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Decompression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: gzipCompressStream error message contains "Compression"`, async (_t) => { + try { + await pipeline([ + createReadableStream(compressibleBody), + gzipCompressStream({ maxOutputSize: 5 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Compression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: deflateDecompressStream error message contains "Decompression"`, async (_t) => { + const input = deflateSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + deflateDecompressStream({ maxOutputSize: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Decompression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: deflateCompressStream error message contains "Compression"`, async (_t) => { + try { + await pipeline([ + createReadableStream(compressibleBody), + deflateCompressStream({ maxOutputSize: 5 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Compression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: brotliDecompressStream error message contains "Decompression"`, async (_t) => { + const input = brotliCompressSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + brotliDecompressStream({ maxOutputSize: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Decompression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: brotliCompressStream error message contains "Compression"`, async (_t) => { + try { + await pipeline([ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: 5 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Compression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: zstdDecompressStream error message contains "Decompression"`, async (_t) => { + const input = zstdCompressSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + zstdDecompressStream({ maxOutputSize: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Decompression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: zstdCompressStream error message contains "Compression"`, async (_t) => { + try { + await pipeline([ + createReadableStream(compressibleBody), + zstdCompressStream({ maxOutputSize: 5 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Compression"), `msg: ${e.message}`); + } + }); + + // Error message includes the maxOutputSize number (covers StringLiteral survivor). + test(`${variant}: gzipDecompressStream error message includes the byte limit value`, async (_t) => { + const input = gzipSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: 42 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("42"), `msg: ${e.message}`); + } + }); +} + +// *** restore after "close" event (StringLiteral "close" → "") *** // +// After a stream closes normally, a second independent stream must still work. +if (variant === "node") { + test(`${variant}: gzipDecompressStream second invocation works after first closes`, async (_t) => { + const input = gzipSync(compressibleBody); + const out1 = await streamToString( + pipejoin([createReadableStream(input), gzipDecompressStream()]), + ); + strictEqual(out1, compressibleBody); + const out2 = await streamToString( + pipejoin([createReadableStream(input), gzipDecompressStream()]), + ); + strictEqual(out2, compressibleBody); + }); + + // After an error event fires the push override must be restored + // (StringLiteral "error" → ""). + test(`${variant}: gzipDecompressStream works correctly after previous maxOutputSize error`, async (_t) => { + const input = gzipSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: 10 }), + ]); + } catch (_e) { + // expected + } + const out = await streamToString( + pipejoin([createReadableStream(input), gzipDecompressStream()]), + ); + strictEqual(out, compressibleBody); + }); +} + +// *** maxOutputSize: null on compress streams (LogicalOperator || survivor) *** // +// `||` mutation: `null !== null || null !== void 0` = true → guardOutput(null) → throws. +// These tests verify null disables the guard on compress streams. +if (variant === "node") { + test(`${variant}: gzipCompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const output = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + gzipCompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, gzipSync(compressibleBody).toString()); + }); + + test(`${variant}: deflateCompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const output = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + deflateCompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, deflateSync(compressibleBody).toString()); + }); + + test(`${variant}: brotliCompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const out = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(brotliDecompressSync(out).toString(), compressibleBody); + }); + + test(`${variant}: zstdCompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const out = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + zstdCompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(zstdDecompressSync(out).toString(), compressibleBody); + }); +} + +// *** zstd quality output size differs (kills ObjectLiteral `params ?? {}`) *** // +// With a 100KB repetitive body, quality:1 and quality:19 DO produce different sizes. +// `params ?? {}` loses the quality → both use default level → same output size. +if (variant === "node") { + const bigRepeat = "x".repeat(100_000); + test(`${variant}: zstdCompressStream quality:1 differs from quality:19 on large body`, async (_t) => { + const out1 = await streamToBuffer( + pipejoin([ + createReadableStream(bigRepeat), + zstdCompressStream({ quality: 1 }), + ]), + ); + const out19 = await streamToBuffer( + pipejoin([ + createReadableStream(bigRepeat), + zstdCompressStream({ quality: 19 }), + ]), + ); + ok( + out1.byteLength !== out19.byteLength, + `quality 1 (${out1.byteLength}B) must differ from quality 19 (${out19.byteLength}B)`, + ); + }); +} + +// *** default export usability (ObjectLiteral `default = {}` survivor) *** // +// The default export must expose compressStream/decompressStream functions; +// if the object is `{}` those calls would throw TypeError. +if (variant === "node") { + test(`${variant}: gzip default export compressStream/decompressStream round-trips`, async (_t) => { + const gzipMod = await import("@datastream/compress/gzip"); + const out = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + gzipMod.default.compressStream(), + gzipMod.default.decompressStream(), + ]), + ); + strictEqual(out, compressibleBody); + }); + + test(`${variant}: deflate default export compressStream/decompressStream round-trips`, async (_t) => { + const defMod = await import("@datastream/compress/deflate"); + const out = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + defMod.default.compressStream(), + defMod.default.decompressStream(), + ]), + ); + strictEqual(out, compressibleBody); + }); + + test(`${variant}: brotli default export compressStream/decompressStream round-trips`, async (_t) => { + const brotliMod = await import("@datastream/compress/brotli"); + const out = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + brotliMod.default.compressStream(), + brotliMod.default.decompressStream(), + ]), + ); + strictEqual(out, compressibleBody); + }); + + test(`${variant}: zstd default export compressStream/decompressStream round-trips`, async (_t) => { + const zstdMod = await import("@datastream/compress/zstd"); + const out = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + zstdMod.default.compressStream(), + zstdMod.default.decompressStream(), + ]), + ); + strictEqual(out, compressibleBody); + }); +} + +// *** AbortSignal honored on node compress/decompress streams *** // +// Aborting mid-flight must cause the pipeline to reject with an AbortError (the +// signal is forwarded into the underlying zlib stream); without signal support +// the pipeline would resolve successfully. A large payload + small chunkSize +// guarantees the stream is still in-flight when the signal fires. +const signalHonored = (e) => + e.name === "AbortError" || e.code === "ABORT_ERR" || /abort/i.test(e.message); +const bigBody = "x".repeat(2_000_000); +if (variant === "node") { + test(`${variant}: gzipCompressStream should reject when signal aborts`, async (_t) => { + const controller = new AbortController(); + const streams = [ + createReadableStream(bigBody, { chunkSize: 1024 }), + gzipCompressStream({}, { signal: controller.signal }), + ]; + const promise = pipeline(streams); + queueMicrotask(() => controller.abort()); + try { + await promise; + throw new Error("Should have thrown"); + } catch (e) { + ok(signalHonored(e), `${e.name}: ${e.message}`); + } + }); + + test(`${variant}: deflateDecompressStream should reject when signal aborts`, async (_t) => { + const controller = new AbortController(); + const input = deflateSync(bigBody); + const streams = [ + createReadableStream(input, { chunkSize: 1024 }), + deflateDecompressStream({}, { signal: controller.signal }), + ]; + const promise = pipeline(streams); + queueMicrotask(() => controller.abort()); + try { + await promise; + throw new Error("Should have thrown"); + } catch (e) { + ok(signalHonored(e), `${e.name}: ${e.message}`); + } + }); +} + +// *** maxOutputSize:null on decompress streams (ungated — works under both runs) *** // +// Covers the `maxOutputSize === null ? void 0 :` true-branch in each decompressor. +// The gzip variant is also covered inside the node-only block; these ungated +// copies ensure the branch fires in the webstream run too. +test(`${variant}: gzipDecompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const input = gzipSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, compressibleBody); +}); + +test(`${variant}: deflateDecompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const input = deflateSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + deflateDecompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, compressibleBody); +}); + +test(`${variant}: brotliDecompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const input = brotliCompressSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + brotliDecompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, compressibleBody); +}); + +// *** guardOutput Buffer.byteLength(chunk) fallback (ungated — both runs) *** // +// guardOutput overrides stream.push and sizes each chunk via +// `chunk.byteLength ?? Buffer.byteLength(chunk)`. zlib only ever pushes Buffers +// (which have .byteLength), so the Buffer.byteLength fallback is reached only +// when a non-BufferSource (a string) is pushed. We grab the guarded stream and +// push a string directly: byteLength is undefined, so the fallback runs. +test(`${variant}: gzipDecompressStream guardOutput sizes string chunks via Buffer.byteLength`, (_t) => { + const stream = gzipDecompressStream({ maxOutputSize: 1024 }); + ok(stream.push("abc") === true); + stream.destroy(); +}); + +test(`${variant}: deflateDecompressStream guardOutput sizes string chunks via Buffer.byteLength`, (_t) => { + const stream = deflateDecompressStream({ maxOutputSize: 1024 }); + ok(stream.push("abc") === true); + stream.destroy(); +}); + +test(`${variant}: brotliDecompressStream guardOutput sizes string chunks via Buffer.byteLength`, (_t) => { + const stream = brotliDecompressStream({ maxOutputSize: 1024 }); + ok(stream.push("abc") === true); + stream.destroy(); +}); + +// String chunk larger than maxOutputSize must trip the guard: this exercises +// Buffer.byteLength(chunk) on the string-sizing path together with the +// `outputSize > maxOutputSize` true branch. push() returns false synchronously. +test(`${variant}: gzipDecompressStream guardOutput rejects oversized string chunk`, (_t) => { + const stream = gzipDecompressStream({ maxOutputSize: 2 }); + // Swallow the destroy() error so it does not surface as an unhandled error. + stream.on("error", () => {}); + strictEqual(stream.push("abcdef"), false); + stream.destroy(); +}); + if (variant === "webstream") { test(`${variant}: gzipDecompressStream should enforce maxOutputSize`, async (_t) => { const input = gzipSync(compressibleBody); @@ -379,4 +1070,563 @@ if (variant === "webstream") { ok(e.message.includes("maxOutputSize")); } }); + + // Brotli compress maxOutputSize — covers guardOutput invocation on the + // compress path (lines 39-41 in brotli.node.mjs) which is gated to node-only + // in the decompression-bomb block but must also fire in the webstream run. + test(`${variant}: brotliCompressStream should enforce maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: 5 }), + ]; + try { + await pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); + + // brotliDecompressStream with params — covers the `params ?` branch in + // brotliDecompressStream (line 46 in brotli.node.mjs). + test(`${variant}: brotliDecompressStream with params should round-trip`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const input = brotliCompressSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + brotliDecompressStream({ + params: { [zlibConst.BROTLI_PARAM_MODE]: 0 }, + }), + ]), + ); + strictEqual(output, compressibleBody); + }); +} + +// *** web (*.web.js) sources, exercised directly *** // +// Node's package export resolution always matches the built-in `node` +// condition first, so `@datastream/compress/` (and even the +// `//webstream` subpath under `node --test`) never loads the *.web.js +// implementations. To actually run the browser code paths we import the +// *.web.js sources by file URL (the same pattern used in packages/kafka), +// drive them through the web build of @datastream/core, and -- because +// brotli-wasm's ESM entry loads its wasm via fetch (unavailable for file:// +// URLs under Node) -- remap the `brotli-wasm` specifier to its synchronous +// node build via a module customization hook. The streaming class API is +// identical across brotli-wasm builds; only wasm loading differs. +{ + const repo = new URL("../../", import.meta.url).pathname; + const coreWebUrl = pathToFileURL(`${repo}packages/core/index.web.mjs`).href; + // Resolve through realpathSync so the brotli-wasm CJS node build is loaded via + // its canonical path. Under a Stryker sandbox `${repo}node_modules` is a + // symlink, and importing the CJS module through the symlinked path yields an + // empty namespace (DecompressStream undefined); the canonical path avoids that. + const brotliWasmNodeUrl = pathToFileURL( + realpathSync(`${repo}node_modules/brotli-wasm/index.node.js`), + ).href; + const loaderSource = ` + export async function resolve(specifier, context, nextResolve) { + if (specifier === "brotli-wasm") { + return { url: ${JSON.stringify(brotliWasmNodeUrl)}, shortCircuit: true }; + } + if (specifier === "@datastream/core") { + return { url: ${JSON.stringify(coreWebUrl)}, shortCircuit: true }; + } + return nextResolve(specifier, context); + } + `; + register( + `data:text/javascript,${encodeURIComponent(loaderSource)}`, + import.meta.url, + ); + + const webModule = (file) => + import(pathToFileURL(`${repo}packages/compress/${file}`).href); + + const webCore = await import(coreWebUrl); + const textDecoder = new TextDecoder(); + const toText = (stream) => + webCore.streamToBuffer(stream).then((buffer) => textDecoder.decode(buffer)); + // node:zlib *Sync helpers return a Buffer that is a view into Node's shared + // allocation pool; copy it into a tightly-sized Uint8Array so the web + // ReadableStream feeds the exact compressed bytes (and nothing trailing). + const webInput = (buffer) => Uint8Array.from(buffer); + + // *** brotli web *** // + const brotliWeb = await webModule("brotli.web.js"); + + test("web-direct: brotliCompressStream should round-trip", async (_t) => { + const streams = [ + webCore.createReadableStream(compressibleBody), + brotliWeb.brotliCompressStream(), + brotliWeb.brotliDecompressStream(), + ]; + strictEqual(await toText(webCore.pipejoin(streams)), compressibleBody); + }); + + test("web-direct: brotliCompressStream output decompresses with node:zlib", async (_t) => { + const streams = [ + webCore.createReadableStream(compressibleBody), + brotliWeb.brotliCompressStream(), + ]; + const output = await webCore.streamToBuffer(webCore.pipejoin(streams)); + ok(output.byteLength > 0); + strictEqual( + brotliDecompressSync(Buffer.from(output)).toString(), + compressibleBody, + ); + }); + + test("web-direct: brotliDecompressStream should decompress node:zlib output", async (_t) => { + const input = webInput(brotliCompressSync(compressibleBody)); + const streams = [ + webCore.createReadableStream(input), + brotliWeb.brotliDecompressStream(), + ]; + strictEqual(await toText(webCore.pipejoin(streams)), compressibleBody); + }); + + test("web-direct: brotliCompressStream should accept quality", async (_t) => { + const streams = [ + webCore.createReadableStream(compressibleBody), + brotliWeb.brotliCompressStream({ quality: 5 }), + brotliWeb.brotliDecompressStream(), + ]; + strictEqual(await toText(webCore.pipejoin(streams)), compressibleBody); + }); + + test("web-direct: brotli should round-trip data larger than the output buffer", async (_t) => { + // 200KB > OUTPUT_SIZE (16KB) exercises the NeedsMoreOutput loop. + const big = "x".repeat(200_000); + const streams = [ + webCore.createReadableStream(big), + brotliWeb.brotliCompressStream(), + brotliWeb.brotliDecompressStream(), + ]; + strictEqual(await toText(webCore.pipejoin(streams)), big); + }); + + test("web-direct: brotliCompressStream should enforce maxOutputSize", async (_t) => { + const streams = [ + webCore.createReadableStream(compressibleBody), + brotliWeb.brotliCompressStream({ maxOutputSize: 5 }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); + + test("web-direct: brotliDecompressStream should enforce maxOutputSize", async (_t) => { + const input = webInput(brotliCompressSync(compressibleBody)); + const streams = [ + webCore.createReadableStream(input), + brotliWeb.brotliDecompressStream({ maxOutputSize: 100 }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); + + test("web-direct: brotliDecompressStream should reject trailing bytes after stream end", async (_t) => { + const compressed = brotliCompressSync(compressibleBody); + // Append garbage after a complete brotli stream; a strict decoder must + // not silently drop it. + const withTrailing = new Uint8Array(compressed.byteLength + 4); + withTrailing.set(compressed, 0); + withTrailing.set([0x01, 0x02, 0x03, 0x04], compressed.byteLength); + const streams = [ + webCore.createReadableStream(withTrailing), + brotliWeb.brotliDecompressStream(), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(/trailing/i.test(e.message), e.message); + } + }); + + // *** gzip web *** // + const gzipWeb = await webModule("gzip.web.js"); + + test("web-direct: gzipCompressStream should round-trip", async (_t) => { + const streams = [ + webCore.createReadableStream(compressibleBody), + gzipWeb.gzipCompressStream(), + gzipWeb.gzipDecompressStream(), + ]; + strictEqual(await toText(webCore.pipejoin(streams)), compressibleBody); + }); + + test("web-direct: gzipDecompressStream should decompress node:zlib output", async (_t) => { + const input = webInput(gzipSync(compressibleBody)); + const streams = [ + webCore.createReadableStream(input), + gzipWeb.gzipDecompressStream(), + ]; + strictEqual(await toText(webCore.pipejoin(streams)), compressibleBody); + }); + + test("web-direct: gzipCompressStream should enforce maxOutputSize", async (_t) => { + const streams = [ + webCore.createReadableStream(compressibleBody), + gzipWeb.gzipCompressStream({ maxOutputSize: 10 }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); + + test("web-direct: gzipDecompressStream should enforce maxOutputSize", async (_t) => { + const input = webInput(gzipSync(compressibleBody)); + const streams = [ + webCore.createReadableStream(input), + gzipWeb.gzipDecompressStream({ maxOutputSize: 100 }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); + + // A spec-strict CompressionStream rejects non-BufferSource chunks (browsers + // throw `TypeError: ... not a BufferSource`). Node's CompressionStream is + // lenient and accepts strings, hiding the bug. We swap in a strict identity + // CompressionStream that throws on any string/non-BufferSource chunk so that + // driving the web compress factory with raw string chunks fails unless the + // factory converts strings to bytes first. Identity output keeps the test + // independent of real gzip framing. + const NativeCompressionStream = globalThis.CompressionStream; + const isBufferSource = (chunk) => + chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk); + const installStrictCompression = () => { + globalThis.CompressionStream = class { + constructor() { + const ts = new TransformStream({ + transform(chunk, controller) { + if (!isBufferSource(chunk)) { + controller.error( + new TypeError("CompressionStream chunk is not a BufferSource"), + ); + return; + } + controller.enqueue(chunk); + }, + }); + this.readable = ts.readable; + this.writable = ts.writable; + } + }; + return () => { + globalThis.CompressionStream = NativeCompressionStream; + }; + }; + + test("web-direct: gzipCompressStream should accept string chunks (BufferSource conversion)", async (_t) => { + const restore = installStrictCompression(); + try { + const gzipWebStrict = await import( + `${pathToFileURL(`${repo}packages/compress/gzip.web.js`).href}?strict=gzip` + ); + // Identity compressor => round-trips through the (real) decompress on + // the bytes the strict compressor accepted; the test fails with a + // TypeError if the factory forwards string chunks to CompressionStream. + const out = await webCore.streamToBuffer( + webCore.pipejoin([ + webCore.createReadableStream(compressibleBody), + gzipWebStrict.gzipCompressStream(), + ]), + ); + strictEqual(new TextDecoder().decode(out), compressibleBody); + } finally { + restore(); + } + }); + + test("web-direct: gzipCompressStream should reject when signal aborts", async (_t) => { + const controller = new AbortController(); + controller.abort(); + const streams = [ + webCore.createReadableStream(compressibleBody), + gzipWeb.gzipCompressStream({}, { signal: controller.signal }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.name === "AbortError" || /abort/i.test(e.message), e.message); + } + }); + + test("web-direct: gzipDecompressStream should reject when signal aborts", async (_t) => { + const controller = new AbortController(); + controller.abort(); + const input = webInput(gzipSync(compressibleBody)); + const streams = [ + webCore.createReadableStream(input), + gzipWeb.gzipDecompressStream({}, { signal: controller.signal }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.name === "AbortError" || /abort/i.test(e.message), e.message); + } + }); + + // *** deflate web *** // + const deflateWeb = await webModule("deflate.web.js"); + + test("web-direct: deflateCompressStream should round-trip", async (_t) => { + const streams = [ + webCore.createReadableStream(compressibleBody), + deflateWeb.deflateCompressStream(), + deflateWeb.deflateDecompressStream(), + ]; + strictEqual(await toText(webCore.pipejoin(streams)), compressibleBody); + }); + + test("web-direct: deflateDecompressStream should decompress node:zlib output", async (_t) => { + const input = webInput(deflateSync(compressibleBody)); + const streams = [ + webCore.createReadableStream(input), + deflateWeb.deflateDecompressStream(), + ]; + strictEqual(await toText(webCore.pipejoin(streams)), compressibleBody); + }); + + test("web-direct: deflateCompressStream should enforce maxOutputSize", async (_t) => { + const streams = [ + webCore.createReadableStream(compressibleBody), + deflateWeb.deflateCompressStream({ maxOutputSize: 10 }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); + + test("web-direct: deflateDecompressStream should enforce maxOutputSize", async (_t) => { + const input = webInput(deflateSync(compressibleBody)); + const streams = [ + webCore.createReadableStream(input), + deflateWeb.deflateDecompressStream({ maxOutputSize: 100 }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); + + test("web-direct: deflateCompressStream should accept string chunks (BufferSource conversion)", async (_t) => { + const restore = installStrictCompression(); + try { + const deflateWebStrict = await import( + `${pathToFileURL(`${repo}packages/compress/deflate.web.js`).href}?strict=deflate` + ); + const out = await webCore.streamToBuffer( + webCore.pipejoin([ + webCore.createReadableStream(compressibleBody), + deflateWebStrict.deflateCompressStream(), + ]), + ); + strictEqual(new TextDecoder().decode(out), compressibleBody); + } finally { + restore(); + } + }); + + test("web-direct: deflateCompressStream should reject when signal aborts", async (_t) => { + const controller = new AbortController(); + controller.abort(); + const streams = [ + webCore.createReadableStream(compressibleBody), + deflateWeb.deflateCompressStream({}, { signal: controller.signal }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.name === "AbortError" || /abort/i.test(e.message), e.message); + } + }); + + test("web-direct: deflateDecompressStream should reject when signal aborts", async (_t) => { + const controller = new AbortController(); + controller.abort(); + const input = webInput(deflateSync(compressibleBody)); + const streams = [ + webCore.createReadableStream(input), + deflateWeb.deflateDecompressStream({}, { signal: controller.signal }), + ]; + try { + await webCore.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.name === "AbortError" || /abort/i.test(e.message), e.message); + } + }); + + // *** zstd web (unsupported in browsers) *** // + const zstdWeb = await webModule("zstd.web.js"); + + test("web-direct: zstdCompressStream should throw Not supported", (_t) => { + try { + zstdWeb.zstdCompressStream(); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Not supported")); + } + }); + + test("web-direct: zstdDecompressStream should throw Not supported", (_t) => { + try { + zstdWeb.zstdDecompressStream(); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Not supported")); + } + }); +} + +// *** guardOutput internals (node *.node.mjs sources) *** // +// These tests pin the monkey-patched push guard installed by guardOutput and the +// limit-resolution branches in each {Compress,Decompress}Stream. They run +// only under --conditions=node because they import the node builds directly. +if (variant === "node") { + const compressFactories = { + gzip: gzipCompressStream, + deflate: deflateCompressStream, + brotli: brotliCompressStream, + zstd: zstdCompressStream, + }; + const decompressFactories = { + gzip: gzipDecompressStream, + deflate: deflateDecompressStream, + brotli: brotliDecompressStream, + zstd: zstdDecompressStream, + }; + + for (const [name, factory] of Object.entries(decompressFactories)) { + // guardOutput must size string chunks via Buffer.byteLength (the `??` + // fallback). With a tiny limit, pushing a string longer than the limit must + // trip the guard: push() returns false. The `&&` mutant makes outputSize + // NaN so the guard never trips and push() returns true. + test(`${variant}: ${name}DecompressStream guardOutput sizes string chunks and trips limit`, (_t) => { + const stream = factory({ maxOutputSize: 2 }); + stream.on("error", () => {}); + strictEqual(stream.push("abc"), false); // 3 bytes > 2 -> destroyed + stream.destroy(); + }); + + // Within the limit, push() returns true and the guard does not trip. + test(`${variant}: ${name}DecompressStream guardOutput passes string chunks within limit`, (_t) => { + const stream = factory({ maxOutputSize: 1024 }); + strictEqual(stream.push("abc"), true); + stream.destroy(); + }); + + // restore() resets stream.push on 'error'. If restore is a no-op, or the + // 'error' listener is registered under the wrong event name, push stays the + // wrapped function. + test(`${variant}: ${name}DecompressStream restores push on error`, (_t) => { + const stream = factory({ maxOutputSize: 100 }); + const wrapped = stream.push; + stream.on("error", () => {}); + stream.emit("error", new Error("boom")); + ok(stream.push !== wrapped, "push must be restored after error"); + stream.destroy(); + }); + + // restore() resets stream.push on 'close' too (separate listener). + test(`${variant}: ${name}DecompressStream restores push on close`, (_t) => { + const stream = factory({ maxOutputSize: 100 }); + const wrapped = stream.push; + stream.emit("close"); + ok(stream.push !== wrapped, "push must be restored after close"); + stream.destroy(); + }); + + // maxOutputSize:null disables the limit entirely: NO guard is installed, so + // the stream carries no extra close/error listeners. The `false ? ...` and + // `if (limit !== undefined) -> if (true)` mutants would install the guard. + test(`${variant}: ${name}DecompressStream maxOutputSize:null installs no guard`, (_t) => { + const stream = factory({ maxOutputSize: null }); + strictEqual(stream.listenerCount("close"), 0); + strictEqual(stream.listenerCount("error"), 0); + stream.destroy(); + }); + + // The default (no maxOutputSize) DOES install the guard (DEFAULT ceiling). + test(`${variant}: ${name}DecompressStream default installs the guard`, (_t) => { + const stream = factory(); + strictEqual(stream.listenerCount("close"), 1); + strictEqual(stream.listenerCount("error"), 1); + stream.destroy(); + }); + } + + for (const [name, factory] of Object.entries(compressFactories)) { + // No maxOutputSize on a compress stream => no guard => no extra listeners. + // The `maxOutputSize !== undefined -> true` mutant would install the guard. + test(`${variant}: ${name}CompressStream without maxOutputSize installs no guard`, (_t) => { + const stream = factory(); + strictEqual(stream.listenerCount("close"), 0); + strictEqual(stream.listenerCount("error"), 0); + stream.destroy(); + }); + + // maxOutputSize:null on a compress stream also installs no guard. + test(`${variant}: ${name}CompressStream maxOutputSize:null installs no guard`, (_t) => { + const stream = factory({ maxOutputSize: null }); + strictEqual(stream.listenerCount("close"), 0); + strictEqual(stream.listenerCount("error"), 0); + stream.destroy(); + }); + + // A finite maxOutputSize installs the guard. + test(`${variant}: ${name}CompressStream with maxOutputSize installs the guard`, (_t) => { + const stream = factory({ maxOutputSize: 1024 }); + strictEqual(stream.listenerCount("close"), 1); + strictEqual(stream.listenerCount("error"), 1); + stream.destroy(); + }); + } + + // ObjectLiteral: brotli/zstd decompress forward `{ ...streamOptions, params }` + // when params are supplied. The `{}` mutant drops streamOptions, so chunkSize + // reverts to the zlib default (16384) instead of the supplied value. + test(`${variant}: brotliDecompressStream forwards streamOptions when params given`, (_t) => { + const stream = brotliDecompressStream( + { params: { [zlibConstants.BROTLI_DECODER_PARAM_LARGE_WINDOW]: 0 } }, + { chunkSize: 8192 }, + ); + strictEqual(stream._chunkSize, 8192); + stream.destroy(); + }); + + test(`${variant}: zstdDecompressStream forwards streamOptions when params given`, (_t) => { + const stream = zstdDecompressStream( + { params: { [zlibConstants.ZSTD_d_windowLogMax]: 27 } }, + { chunkSize: 8192 }, + ); + strictEqual(stream._chunkSize, 8192); + stream.destroy(); + }); } diff --git a/packages/compress/index.tst.ts b/packages/compress/index.tst.ts index 0a8cd51..2c0ab3b 100644 --- a/packages/compress/index.tst.ts +++ b/packages/compress/index.tst.ts @@ -7,8 +7,6 @@ import { deflateDecompressStream, gzipCompressStream, gzipDecompressStream, - protobufDeserializeStream, - protobufSerializeStream, zstdCompressStream, zstdDecompressStream, } from "@datastream/compress"; @@ -55,21 +53,3 @@ describe("zstd", () => { expect(zstdDecompressStream()).type.not.toBeAssignableTo(); }); }); - -describe("protobuf", () => { - test("protobufSerializeStream accepts Type", () => { - expect( - protobufSerializeStream({ - Type: {} as import("@datastream/compress/protobuf").ProtobufType, - }), - ).type.not.toBeAssignableTo(); - }); - - test("protobufDeserializeStream accepts Type", () => { - expect( - protobufDeserializeStream({ - Type: {} as import("@datastream/compress/protobuf").ProtobufType, - }), - ).type.not.toBeAssignableTo(); - }); -}); diff --git a/packages/compress/package.json b/packages/compress/package.json index f3f8621..420216e 100644 --- a/packages/compress/package.json +++ b/packages/compress/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/compress", - "version": "0.5.0", + "version": "0.6.1", "description": "Compression and decompression streams for gzip, deflate, brotli, and zstd", "type": "module", "engines": { @@ -92,22 +92,6 @@ "./deflate/webstream": { "types": "./deflate.d.ts", "default": "./deflate.web.mjs" - }, - "./protobuf": { - "node": { - "import": { - "types": "./protobuf.d.ts", - "default": "./protobuf.node.mjs" - } - }, - "import": { - "types": "./protobuf.d.ts", - "default": "./protobuf.web.mjs" - } - }, - "./protobuf/webstream": { - "types": "./protobuf.d.ts", - "default": "./protobuf.web.mjs" } }, "types": "index.d.ts", @@ -119,7 +103,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -140,27 +125,17 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" }, "devDependencies": { - "brotli-wasm": "^3.0.0", - "protobufjs": "^8.6.2", - "zstd-codec": "^0.1.0" + "brotli-wasm": "^3.0.0" }, "peerDependencies": { - "brotli-wasm": "^3.0.0", - "protobufjs": "^8.6.2", - "zstd-codec": "^0.1.0" + "brotli-wasm": "^3.0.0" }, "peerDependenciesMeta": { "brotli-wasm": { "optional": true - }, - "protobufjs": { - "optional": true - }, - "zstd-codec": { - "optional": true } } } diff --git a/packages/compress/protobuf.d.ts b/packages/compress/protobuf.d.ts deleted file mode 100644 index 3f55cbe..0000000 --- a/packages/compress/protobuf.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2026 will Farrell, and datastream contributors. -// SPDX-License-Identifier: MIT -import type { DatastreamTransform, StreamOptions } from "@datastream/core"; - -export interface ProtobufType { - encode(message: unknown): { finish(): Uint8Array }; - decode(buffer: Uint8Array): unknown; - toObject(message: unknown): Record; -} - -export function protobufSerializeStream( - options?: { Type?: ProtobufType }, - streamOptions?: StreamOptions, -): DatastreamTransform; -export function protobufDeserializeStream( - options?: { Type?: ProtobufType }, - streamOptions?: StreamOptions, -): DatastreamTransform; diff --git a/packages/compress/protobuf.node.js b/packages/compress/protobuf.node.js deleted file mode 100644 index eb743ef..0000000 --- a/packages/compress/protobuf.node.js +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2026 will Farrell, and datastream contributors. -// SPDX-License-Identifier: MIT -import { createTransformStream } from "@datastream/core"; - -export const protobufSerializeStream = ({ Type } = {}, streamOptions = {}) => { - const transform = (chunk, enqueue) => { - enqueue(Type.encode(Type.create(chunk)).finish()); - }; - return createTransformStream(transform, streamOptions); -}; - -export const protobufDeserializeStream = ( - { Type, maxOutputSize } = {}, - streamOptions = {}, -) => { - let outputSize = 0; - const transform = (chunk, enqueue) => { - const decoded = Type.decode(chunk); - if (maxOutputSize != null) { - const encoded = JSON.stringify(decoded); - outputSize += encoded?.length ?? 0; - if (outputSize > maxOutputSize) { - throw new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ); - } - } - enqueue(decoded); - }; - return createTransformStream(transform, streamOptions); -}; - -export default { - serializeStream: protobufSerializeStream, - deserializeStream: protobufDeserializeStream, -}; diff --git a/packages/compress/protobuf.web.js b/packages/compress/protobuf.web.js deleted file mode 100644 index eb743ef..0000000 --- a/packages/compress/protobuf.web.js +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2026 will Farrell, and datastream contributors. -// SPDX-License-Identifier: MIT -import { createTransformStream } from "@datastream/core"; - -export const protobufSerializeStream = ({ Type } = {}, streamOptions = {}) => { - const transform = (chunk, enqueue) => { - enqueue(Type.encode(Type.create(chunk)).finish()); - }; - return createTransformStream(transform, streamOptions); -}; - -export const protobufDeserializeStream = ( - { Type, maxOutputSize } = {}, - streamOptions = {}, -) => { - let outputSize = 0; - const transform = (chunk, enqueue) => { - const decoded = Type.decode(chunk); - if (maxOutputSize != null) { - const encoded = JSON.stringify(decoded); - outputSize += encoded?.length ?? 0; - if (outputSize > maxOutputSize) { - throw new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ); - } - } - enqueue(decoded); - }; - return createTransformStream(transform, streamOptions); -}; - -export default { - serializeStream: protobufSerializeStream, - deserializeStream: protobufDeserializeStream, -}; diff --git a/packages/compress/zstd.node.js b/packages/compress/zstd.node.js index 782617a..b980034 100644 --- a/packages/compress/zstd.node.js +++ b/packages/compress/zstd.node.js @@ -2,40 +2,61 @@ // SPDX-License-Identifier: MIT import { constants, createZstdCompress, createZstdDecompress } from "node:zlib"; -export const zstdCompressStream = (options = {}, _streamOptions = {}) => { - const { quality, ...rest } = options; - return createZstdCompress({ - ...rest, - params: rest.params ?? { +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const guardOutput = (stream, maxOutputSize, label) => { + let outputSize = 0; + const originalPush = stream.push.bind(stream); + stream.push = (chunk, encoding) => { + if (chunk !== null) { + outputSize += chunk.byteLength ?? Buffer.byteLength(chunk); + if (outputSize > maxOutputSize) { + stream.push = originalPush; + stream.destroy( + new Error( + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ), + ); + return false; + } + } + return originalPush(chunk, encoding); + }; + const restore = () => { + stream.push = originalPush; + }; + stream.on("close", restore); + stream.on("error", restore); +}; + +export const zstdCompressStream = (options = {}, streamOptions = {}) => { + const { quality, maxOutputSize, params } = options; + const stream = createZstdCompress({ + ...streamOptions, + params: params ?? { [constants.ZSTD_c_compressionLevel]: quality ?? constants.ZSTD_CLEVEL_DEFAULT, }, }); + if (maxOutputSize !== null && maxOutputSize !== undefined) { + guardOutput(stream, maxOutputSize, "Compression"); + } + return stream; }; -export const zstdDecompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize, ...rest } = options; - const stream = createZstdDecompress(rest); - if (maxOutputSize != null) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); +export const zstdDecompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize, params } = options; + const stream = createZstdDecompress( + params ? { ...streamOptions, params } : streamOptions, + ); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + if (limit !== undefined) { + guardOutput(stream, limit, "Decompression"); } return stream; }; diff --git a/packages/core/README.md b/packages/core/README.md index 757cdbe..81466f5 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,6 +1,6 @@

<datastream> `core`

- datastream logo + datastream logo

Core component of the datastream framework, the robust JavaScript streaming utility.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/core/index.node.js b/packages/core/index.node.js index f50d0bd..aba48e0 100644 --- a/packages/core/index.node.js +++ b/packages/core/index.node.js @@ -9,36 +9,70 @@ const NULL_SENTINEL = Symbol.for("@datastream/null"); const toSafe = (v) => (v === null ? NULL_SENTINEL : v); const fromSafe = (v) => (v === NULL_SENTINEL ? null : v); +// streamToObject accumulates onto an Object.create(null) and previously +// returned `{ ...value }`. If any chunk carried an own `__proto__` key (e.g. +// from JSON.parse of untrusted input), the spread copied it as an own +// enumerable `__proto__` data property on the returned plain object — a +// surprising contract that confuses prototype-based checks. Strip any own +// `__proto__` key and return a plain object with a normal prototype. +const sanitizeObject = (value) => { + const out = {}; + for (const key of Object.keys(value)) { + if (key === "__proto__") continue; + out[key] = value[key]; + } + return out; +}; + export const pipeline = async (streams, streamOptions = {}) => { for (let idx = 0, l = streams.length; idx < l; idx++) { if (typeof streams[idx].then === "function") { throw new Error(`Promise instead of stream passed in at index ${idx}`); } } + // Work on copies so appending the terminal writable (and deriving its + // objectMode) doesn't mutate the caller's array or options object. + streams = [...streams]; // Ensure stream ends with only writable const lastStream = streams[streams.length - 1]; if (isReadable(lastStream)) { - streamOptions.objectMode = lastStream._readableState.objectMode; + streamOptions = { + ...streamOptions, + objectMode: lastStream._readableState.objectMode, + }; streams.push(createWritableStream(() => {}, streamOptions)); } await pipelinePromise(streams, streamOptions); return result(streams); }; -export const pipejoin = ( - streams, - onError = (e) => { - process.nextTick(() => { - throw e; - }); - }, -) => { - const pipeline = streams.reduce((pipeline, stream, idx) => { - if (typeof stream.then === "function") { +export const pipejoin = (streams, onError) => { + for (let idx = 0, l = streams.length; idx < l; idx++) { + if (typeof streams[idx].then === "function") { throw new Error(`Promise instead of stream passed in at index ${idx}`); } - return pipeline.pipe(stream).on("error", onError); - }); + } + let settled = false; + const teardown = (error) => { + if (settled) return; + settled = true; + for (const stream of streams) { + if (!stream.destroyed) stream.destroy(error); + } + if (onError) { + onError(error); + } else { + process.nextTick(() => { + throw error; + }); + } + }; + let pipeline = streams[0]; + pipeline.on("error", teardown); + for (let idx = 1, l = streams.length; idx < l; idx++) { + pipeline = pipeline.pipe(streams[idx]); + pipeline.on("error", teardown); + } return pipeline; }; @@ -73,32 +107,44 @@ export const backpressureGauge = (streams) => { // Number.parseInt( (process.hrtime.bigint() - pauseTimestamp).toString() , 10 ) / 1_000_000 // ms const duration = Date.now() - timestamp; metrics[keys[i]].timeline.push({ timestamp, duration }); + // Clear so a second resume without an intervening pause can't + // record a phantom interval from the stale pause timestamp. + timestamp = undefined; } }); - value.on("end", () => { + // Readable streams emit 'end'; writable (sink) streams emit 'finish' + // and never 'end', so listen for both (plus 'close' as a backstop) to + // record total duration for writable/duplex nodes too. Guard against + // double-recording when more than one terminal event fires. + const recordTotal = () => { + if (metrics[keys[i]].total.timestamp != null) return; const duration = Date.now() - startTimestamp; metrics[keys[i]].total = { timestamp: startTimestamp, duration }; - }); + }; + value.on("end", recordTotal); + value.on("finish", recordTotal); + value.on("close", recordTotal); } return metrics; }; -export const streamToArray = (stream, { maxBufferSize } = {}) => { +export const streamToArray = ( + stream, + { maxBufferSize = Number.POSITIVE_INFINITY } = {}, +) => { if (typeof stream.on === "function") { return new Promise((resolve, reject) => { const value = []; let size = 0; stream.on("data", (chunk) => { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 1; - if (size > maxBufferSize) { - stream.destroy( - new Error( - `streamToArray buffer exceeds maxBufferSize (${maxBufferSize})`, - ), - ); - return; - } + size += chunk?.length ?? chunk?.byteLength ?? 1; + if (size > maxBufferSize) { + stream.destroy( + new Error( + `streamToArray buffer exceeds maxBufferSize (${maxBufferSize})`, + ), + ); + return; } value.push(fromSafe(chunk)); }); @@ -112,41 +158,43 @@ export const streamToArray = (stream, { maxBufferSize } = {}) => { const value = []; let size = 0; for await (const chunk of stream) { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 1; - if (size > maxBufferSize) { - throw new Error( - `streamToArray buffer exceeds maxBufferSize (${maxBufferSize})`, - ); - } + size += chunk?.length ?? chunk?.byteLength ?? 1; + if (size > maxBufferSize) { + throw new Error( + `streamToArray buffer exceeds maxBufferSize (${maxBufferSize})`, + ); } - value.push(chunk); + // Decode the null sentinel here too so both consumption paths agree. + value.push(fromSafe(chunk)); } return value; })(); }; -export const streamToObject = (stream, { maxBufferSize } = {}) => { +export const streamToObject = ( + stream, + { maxBufferSize = Number.POSITIVE_INFINITY } = {}, +) => { if (typeof stream.on === "function") { return new Promise((resolve, reject) => { const value = Object.create(null); let size = 0; stream.on("data", (chunk) => { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 1; - if (size > maxBufferSize) { - stream.destroy( - new Error( - `streamToObject buffer exceeds maxBufferSize (${maxBufferSize})`, - ), - ); - return; - } + size += chunk?.length ?? chunk?.byteLength ?? 1; + if (size > maxBufferSize) { + stream.destroy( + new Error( + `streamToObject buffer exceeds maxBufferSize (${maxBufferSize})`, + ), + ); + return; } - Object.assign(value, chunk); + // Unwrap the null sentinel so a null-bearing object stream doesn't + // leak the Symbol; Object.assign(value, null) is a safe no-op. + Object.assign(value, fromSafe(chunk)); }); stream.on("end", () => { - resolve({ ...value }); + resolve(sanitizeObject(value)); }); stream.on("error", reject); }); @@ -155,38 +203,39 @@ export const streamToObject = (stream, { maxBufferSize } = {}) => { const value = Object.create(null); let size = 0; for await (const chunk of stream) { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 1; - if (size > maxBufferSize) { - throw new Error( - `streamToObject buffer exceeds maxBufferSize (${maxBufferSize})`, - ); - } + size += chunk?.length ?? chunk?.byteLength ?? 1; + if (size > maxBufferSize) { + throw new Error( + `streamToObject buffer exceeds maxBufferSize (${maxBufferSize})`, + ); } - Object.assign(value, chunk); + Object.assign(value, fromSafe(chunk)); } - return { ...value }; + return sanitizeObject(value); })(); }; -export const streamToString = (stream, { maxBufferSize } = {}) => { +export const streamToString = ( + stream, + { maxBufferSize = Number.POSITIVE_INFINITY } = {}, +) => { if (typeof stream.on === "function") { return new Promise((resolve, reject) => { const chunks = []; let size = 0; stream.on("data", (chunk) => { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 0; - if (size > maxBufferSize) { - stream.destroy( - new Error( - `streamToString buffer exceeds maxBufferSize (${maxBufferSize})`, - ), - ); - return; - } + size += chunk?.length ?? chunk?.byteLength ?? 0; + if (size > maxBufferSize) { + stream.destroy( + new Error( + `streamToString buffer exceeds maxBufferSize (${maxBufferSize})`, + ), + ); + return; } - chunks.push(chunk); + // Unwrap the null sentinel; otherwise join("") throws + // "Cannot convert a Symbol value to a string". + chunks.push(fromSafe(chunk)); }); stream.on("end", () => { resolve(chunks.join("")); @@ -198,37 +247,38 @@ export const streamToString = (stream, { maxBufferSize } = {}) => { const chunks = []; let size = 0; for await (const chunk of stream) { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 0; - if (size > maxBufferSize) { - throw new Error( - `streamToString buffer exceeds maxBufferSize (${maxBufferSize})`, - ); - } + size += chunk?.length ?? chunk?.byteLength ?? 0; + if (size > maxBufferSize) { + throw new Error( + `streamToString buffer exceeds maxBufferSize (${maxBufferSize})`, + ); } - chunks.push(chunk); + chunks.push(fromSafe(chunk)); } return chunks.join(""); })(); }; -export const streamToBuffer = (stream, { maxBufferSize } = {}) => { +export const streamToBuffer = ( + stream, + { maxBufferSize = Number.POSITIVE_INFINITY } = {}, +) => { if (typeof stream.on === "function") { return new Promise((resolve, reject) => { const value = []; let size = 0; stream.on("data", (chunk) => { - const buf = Buffer.from(chunk); - if (maxBufferSize != null) { - size += buf.length; - if (size > maxBufferSize) { - stream.destroy( - new Error( - `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, - ), - ); - return; - } + // Unwrap the null sentinel; Buffer.from(symbol) throws + // ERR_INVALID_ARG_TYPE. fromSafe(null) -> null -> empty buffer. + const buf = Buffer.from(fromSafe(chunk) ?? []); + size += buf.length; + if (size > maxBufferSize) { + stream.destroy( + new Error( + `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, + ), + ); + return; } value.push(buf); }); @@ -242,14 +292,12 @@ export const streamToBuffer = (stream, { maxBufferSize } = {}) => { const value = []; let size = 0; for await (const chunk of stream) { - const buf = Buffer.from(chunk); - if (maxBufferSize != null) { - size += buf.length; - if (size > maxBufferSize) { - throw new Error( - `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, - ); - } + const buf = Buffer.from(fromSafe(chunk) ?? []); + size += buf.length; + if (size > maxBufferSize) { + throw new Error( + `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, + ); } value.push(buf); } @@ -309,7 +357,7 @@ export const createReadableStream = (input, streamOptions = {}) => { if (typeof input === "string") { return createReadableStreamFromString(input, streamOptions); } - if (typeof input === "object" && input.byteLength) { + if (input.byteLength) { return createReadableStreamFromArrayBuffer(input, streamOptions); } if (Array.isArray(input)) { @@ -520,18 +568,13 @@ export const timeout = (ms, { signal } = {}) => { ); } return new Promise((resolve, reject) => { - let settled = false; const abortHandler = () => { - if (settled) return; - settled = true; clearTimeout(timerId); signal.removeEventListener("abort", abortHandler); reject(new Error("Aborted", { cause: { code: "AbortError" } })); }; if (signal) signal.addEventListener("abort", abortHandler); const timerId = setTimeout(() => { - if (settled) return; - settled = true; if (signal) signal.removeEventListener("abort", abortHandler); resolve(); }, ms); diff --git a/packages/core/index.test.js b/packages/core/index.test.js index f1a3c8f..a6d4d57 100644 --- a/packages/core/index.test.js +++ b/packages/core/index.test.js @@ -1,4 +1,6 @@ import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; import test, { mock } from "node:test"; import { backpressureGauge, @@ -165,6 +167,22 @@ test(`${variant}: streamToBuffer should work with Uint8Array`, async (_t) => { deepStrictEqual(output.toString(), "hello"); }); +// The bare `@datastream/core` import resolves to the node build under both +// --conditions (Node always injects the `node` condition first), so the web +// build is only actually exercised by importing it directly. streamToBuffer +// was missing from the web build entirely; assert parity here. +test(`${variant}: web build exports a working streamToBuffer`, async (_t) => { + const web = await import( + `file://${new URL("./index.web.js", import.meta.url).pathname}` + ); + strictEqual(typeof web.streamToBuffer, "function"); + const out = await web.streamToBuffer( + web.createReadableStream(["hello", " ", "world"]), + ); + ok(out instanceof Uint8Array); + strictEqual(new TextDecoder().decode(out), "hello world"); +}); + // *** backpressureGauge *** // test(`${variant}: backpressureGauge should measure stream metrics`, async (_t) => { const input = ["a", "b", "c"]; @@ -219,6 +237,19 @@ test(`${variant}: backpressureGauge should track resume without prior pause`, as strictEqual(typeof metrics.transform.total.duration, "number"); }); +test(`${variant}: backpressureGauge records one interval per pause/resume pair`, async (_t) => { + const transform = createTransformStream(); + const metrics = backpressureGauge({ transform }); + + // One real pause/resume pair, then a stray resume with no intervening pause. + transform.emit("pause"); + transform.emit("resume"); + transform.emit("resume"); + + // The stray resume must not record a phantom interval from the stale pause. + strictEqual(metrics.transform.timeline.length, 1); +}); + // *** createReadableStream *** // test(`${variant}: createReadableStream should create a readable stream from string`, async (_t) => { const input = "abc"; @@ -286,6 +317,20 @@ test(`${variant}: createReadableStream should allow pushing values onto it`, asy deepStrictEqual(output, ["a"]); }); +test(`${variant}: createReadableStream read() is invoked when consumer requests data before push`, async (_t) => { + // Start consuming BEFORE pushing so the Readable's _read() hook is triggered + // when the stream is in flowing mode but the buffer is empty. + const source = createReadableStream(); + const outputPromise = streamToArray(source); + // Push data asynchronously so _read() fires first. + process.nextTick(() => { + source.push("x"); + source.push(null); + }); + const output = await outputPromise; + deepStrictEqual(output, ["x"]); +}); + if (variant === "node") { const { backpressureGauge } = await import("@datastream/core"); test(`${variant}: backpressureGauge should chunk really long strings`, async (_t) => { @@ -811,20 +856,18 @@ test(`${variant}: streamToBuffer should throw when exceeding maxBufferSize`, asy }); // *** createReadableStream queue limit regression *** // -if (variant === "node") { - test(`${variant}: createReadableStream should throw when queue exceeds limit`, async (_t) => { - const stream = createReadableStream(undefined, { highWaterMark: 3 }); - stream.push("a"); - stream.push("b"); - stream.push("c"); - try { - stream.push("d"); - throw new Error("Should have thrown"); - } catch (e) { - ok(e.message.includes("exceeds limit")); - } - }); -} +test(`${variant}: createReadableStream should throw when queue exceeds limit`, async (_t) => { + const stream = createReadableStream(undefined, { highWaterMark: 3 }); + stream.push("a"); + stream.push("b"); + stream.push("c"); + try { + stream.push("d"); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("exceeds limit")); + } +}); // *** deepClone / deepEqual error paths *** // test(`${variant}: deepClone throws for non-cloneable values`, () => { @@ -884,3 +927,1479 @@ test(`${variant}: deepEqual via JSON serialization`, () => { strictEqual(deepEqual({ a: { b: 1 } }, { a: { b: 1 } }), true); strictEqual(deepEqual({ a: { b: 1 } }, { a: { b: 2 } }), false); }); + +// *** Node NULL_SENTINEL collector regression *** // +// The documented way to flow a null through a node object-mode stream is +// enqueue(null), which createTransformStream wraps as NULL_SENTINEL. Every +// collector must unwrap it via fromSafe, not just streamToArray. +test(`${variant}: streamToArray unwraps NULL_SENTINEL from object stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => enqueue(null)), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [null]); +}); + +test(`${variant}: streamToString unwraps NULL_SENTINEL from object stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => enqueue(null)), + ]; + const output = await streamToString(pipejoin(streams)); + // fromSafe(null) -> null; "".join semantics turn null into an empty string. + strictEqual(output, ""); +}); + +test(`${variant}: streamToObject unwraps NULL_SENTINEL from object stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => { + enqueue({ x: 1 }); + enqueue(null); + }), + ]; + // Object.assign(value, null) is a no-op, so the null chunk must not throw. + const output = await streamToObject(pipejoin(streams)); + deepStrictEqual(output, { x: 1 }); +}); + +test(`${variant}: streamToBuffer unwraps NULL_SENTINEL from object stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => { + enqueue("hi"); + enqueue(null); + }), + ]; + // Buffer.from(null) throws; fromSafe(null) must yield an empty buffer. + const output = await streamToBuffer(pipejoin(streams)); + strictEqual(output.toString(), "hi"); +}); + +// *** Node backpressureGauge writable total regression *** // +if (variant === "node") { + test(`${variant}: backpressureGauge records total for writable sinks`, async (_t) => { + const writable = createWritableStream(); + const streams = { + readable: createReadableStream(["a", "b", "c"]), + writable, + }; + const metrics = backpressureGauge(streams); + await pipeline(Object.values(streams)); + // Writable streams emit 'finish'/'close', not 'end', so total must be + // recorded from those lifecycle events too. + strictEqual(typeof metrics.writable.total.timestamp, "number"); + strictEqual(typeof metrics.writable.total.duration, "number"); + }); +} + +// *** Node streamToObject __proto__ contract regression *** // +test(`${variant}: streamToObject does not expose own __proto__ data key`, async (_t) => { + // JSON.parse produces an own __proto__ key; the accumulator must not surface + // it as an own enumerable property on the returned object. + const evil = JSON.parse('{"__proto__":{"polluted":true},"safe":1}'); + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => enqueue(evil)), + ]; + const output = await streamToObject(pipejoin(streams)); + strictEqual(Object.hasOwn(output, "__proto__"), false); + strictEqual(output.safe, 1); + // And no global prototype pollution occurred. + strictEqual({}.polluted, undefined); +}); + +// *** Web build direct-import regressions *** // +// The bare `@datastream/core` import always resolves to the node build, so the +// web source is exercised only by importing it directly here. +const loadWeb = () => + import(`file://${new URL("./index.web.js", import.meta.url).pathname}`); + +test(`${variant}: web streamToString decodes byte chunks instead of comma-joining`, async (_t) => { + const web = await loadWeb(); + async function* gen() { + yield new TextEncoder().encode("hi"); + yield new TextEncoder().encode("!"); + } + const output = await web.streamToString(gen()); + strictEqual(output, "hi!"); +}); + +test(`${variant}: web streamToString decodes multibyte split across chunks`, async (_t) => { + const web = await loadWeb(); + const bytes = new TextEncoder().encode("é"); // 2 bytes: 0xC3 0xA9 + async function* gen() { + yield bytes.subarray(0, 1); + yield bytes.subarray(1); + } + const output = await web.streamToString(gen()); + strictEqual(output, "é"); +}); + +test(`${variant}: web streamToString still joins string chunks`, async (_t) => { + const web = await loadWeb(); + async function* gen() { + yield "hello"; + yield " world"; + } + const output = await web.streamToString(gen()); + strictEqual(output, "hello world"); +}); + +test(`${variant}: web createReadableStream honors typed-array byteOffset/byteLength`, async (_t) => { + const web = await loadWeb(); + // A subarray view over a larger buffer must stream only its own window, + // not the whole backing ArrayBuffer (adjacent-heap leak otherwise). + const full = new Uint8Array([0, 0, 1, 2, 3, 0, 0]); + const view = full.subarray(2, 5); // [1,2,3] + const out = await web.streamToBuffer(web.createReadableStream(view)); + deepStrictEqual(Array.from(out), [1, 2, 3]); +}); + +test(`${variant}: web createReadableStream honors Buffer byteOffset/byteLength`, async (_t) => { + const web = await loadWeb(); + // Node Buffers share a pooled allocation; reading the whole backing buffer + // would leak pooled bytes and yield far more than 5 bytes. + const buf = Buffer.from([1, 2, 3, 4, 5]); + const out = await web.streamToBuffer(web.createReadableStream(buf)); + deepStrictEqual(Array.from(out), [1, 2, 3, 4, 5]); +}); + +test(`${variant}: web create*Stream remove the abort listener on error`, async (_t) => { + const web = await loadWeb(); + const controller = new AbortController(); + const { signal } = controller; + const before = signal.removeEventListener; + let removed = 0; + // Count removeEventListener calls for 'abort' to detect listener cleanup. + signal.removeEventListener = function (type, ...rest) { + if (type === "abort") removed += 1; + return before.call(this, type, ...rest); + }; + const streams = [ + web.createReadableStream(["a", "b", "c"]), + web.createTransformStream( + () => { + throw new Error("boom"); + }, + { signal }, + ), + web.createWritableStream(() => {}, { signal }), + ]; + try { + await web.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "boom"); + } + // Both the transform and the writable registered an abort listener; both + // must be removed on the error path. + ok(removed >= 2, `expected >=2 abort listener removals, got ${removed}`); +}); + +// Race against a short timeout: an unsupported input must error promptly, not +// hang the stream forever (which would otherwise stall the whole test run). +const withTimeout = (promise, ms = 1000) => + Promise.race([ + promise, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("hung: stream never settled")), ms), + ), + ]); + +test(`${variant}: web createReadableStream errors on unsupported scalar input`, async (_t) => { + const web = await loadWeb(); + try { + await withTimeout(web.streamToArray(web.createReadableStream(42))); + throw new Error("Should have thrown"); + } catch (e) { + ok(e instanceof TypeError, `expected TypeError, got ${e}`); + ok(e.message.includes("unsupported input")); + } +}); + +test(`${variant}: web createReadableStream errors clearly on null input`, async (_t) => { + const web = await loadWeb(); + try { + await withTimeout(web.streamToArray(web.createReadableStream(null))); + throw new Error("Should have thrown"); + } catch (e) { + ok(e instanceof TypeError, `expected TypeError, got ${e}`); + ok(e.message.includes("unsupported input")); + } +}); + +test(`${variant}: web isReadable/isWritable return false for null/undefined`, async (_t) => { + const web = await loadWeb(); + strictEqual(web.isReadable(null), false); + strictEqual(web.isReadable(undefined), false); + strictEqual(web.isWritable(null), false); + strictEqual(web.isWritable(undefined), false); + // And primitives must not throw either. + strictEqual(web.isReadable(42), false); + strictEqual(web.isWritable("x"), false); +}); + +// =========================================================================== +// *** Mutation-killing tests (node build) *** +// Each test below pins a specific behavior so a one-line mutation of the +// source would flip an assertion. Grouped by the source construct targeted. +// =========================================================================== + +// --- EventEmitter-only collector path (the `typeof stream.on === "function"` +// branch). A Node Readable is also async-iterable, so deleting the .on branch +// still works via the async path. A bare EventEmitter is NOT async-iterable, so +// only the .on path can drain it; this distinguishes the two code paths. --- +const emitterStream = (chunks) => { + const ee = new EventEmitter(); + // Not async-iterable on purpose: no Symbol.asyncIterator, no .pipe. + process.nextTick(() => { + for (const c of chunks) ee.emit("data", c); + ee.emit("end"); + }); + return ee; +}; + +if (variant === "node") { + test(`${variant}: streamToArray uses the .on path for plain EventEmitters`, async (_t) => { + const out = await streamToArray(emitterStream(["a", "b", "c"])); + deepStrictEqual(out, ["a", "b", "c"]); + }); + + test(`${variant}: streamToString uses the .on path for plain EventEmitters`, async (_t) => { + const out = await streamToString(emitterStream(["a", "b", "c"])); + strictEqual(out, "abc"); + }); + + test(`${variant}: streamToObject uses the .on path for plain EventEmitters`, async (_t) => { + const out = await streamToObject(emitterStream([{ a: 1 }, { b: 2 }])); + deepStrictEqual(out, { a: 1, b: 2 }); + }); + + test(`${variant}: streamToBuffer uses the .on path for plain EventEmitters`, async (_t) => { + const out = await streamToBuffer( + emitterStream([Buffer.from("ab"), Buffer.from("c")]), + ); + strictEqual(out.toString(), "abc"); + }); + + // The .on path must reject on 'error' (kills deletion of stream.on("error")). + test(`${variant}: streamToArray .on path rejects on error event`, async (_t) => { + const ee = new EventEmitter(); + ee.destroy = () => {}; + process.nextTick(() => ee.emit("error", new Error("boom"))); + try { + await withTimeout(streamToArray(ee)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "boom"); + } + }); +} + +// --- maxBufferSize boundary precision for every collector. The threshold is a +// strict `>` and the running total is an ADDITION. These pin `>` (not >=, <=), +// the `+=` (not -=), and that the boundary value itself does NOT throw. --- + +// streamToArray: object-mode length fallback is `?? 1` per chunk. +test(`${variant}: streamToArray counts each non-sized chunk as 1 (?? 1 fallback)`, async (_t) => { + // 3 plain objects -> size 3. maxBufferSize 3 must pass (boundary, not > ). + const ok3 = await streamToArray(createReadableStream([{}, {}, {}]), { + maxBufferSize: 3, + }); + strictEqual(ok3.length, 3); + // A 4th identical chunk -> size 4 > 3 must throw (kills `?? 0`, `-=`, `>=`). + try { + await streamToArray(createReadableStream([{}, {}, {}, {}]), { + maxBufferSize: 3, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToArray at exact maxBufferSize boundary does not throw`, async (_t) => { + // "aaa"+"bbb" = 6 chars; maxBufferSize 6 is the boundary and must pass. + const out = await streamToArray(createReadableStream(["aaa", "bbb"]), { + maxBufferSize: 6, + }); + deepStrictEqual(out, ["aaa", "bbb"]); +}); + +test(`${variant}: streamToArray uses byteLength when length is absent`, async (_t) => { + // Uint8Array has byteLength but no string length; size must accrue 5. + const chunk = Uint8Array.from([1, 2, 3, 4, 5]); + try { + await streamToArray(createReadableStream([chunk]), { maxBufferSize: 4 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + // And 5 (exact) passes. + const out = await streamToArray(createReadableStream([chunk]), { + maxBufferSize: 5, + }); + strictEqual(out.length, 1); +}); + +test(`${variant}: streamToString at exact boundary passes, one over throws`, async (_t) => { + const ok6 = await streamToString(createReadableStream(["aaa", "bbb"]), { + maxBufferSize: 6, + }); + strictEqual(ok6, "aaabbb"); + try { + await streamToString(createReadableStream(["aaa", "bbbc"]), { + maxBufferSize: 6, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToString non-sized chunk fallback is 0, not 1`, async (_t) => { + // streamToString uses `?? 0`: an object with no length contributes 0, so a + // tiny maxBufferSize must NOT trip on object-mode chunks. (Kills `?? 1`.) + const out = await streamToString(createReadableStream([{}, {}, {}]), { + maxBufferSize: 0, + }); + // String(...) of objects joined; just assert it did not throw. + strictEqual(typeof out, "string"); +}); + +test(`${variant}: streamToObject at exact boundary passes, one over throws`, async (_t) => { + // Each object counts as 1 (?? 1). 3 objects, boundary 3 passes. + const ok3 = await streamToObject( + createReadableStream([{ a: 1 }, { b: 2 }, { c: 3 }]), + { maxBufferSize: 3 }, + ); + deepStrictEqual(ok3, { a: 1, b: 2, c: 3 }); + try { + await streamToObject( + createReadableStream([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]), + { maxBufferSize: 3 }, + ); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToBuffer at exact boundary passes, one over throws`, async (_t) => { + const ok6 = await streamToBuffer( + createReadableStream([Buffer.from("aaa"), Buffer.from("bbb")]), + { maxBufferSize: 6 }, + ); + strictEqual(ok6.toString(), "aaabbb"); + try { + await streamToBuffer( + createReadableStream([Buffer.from("aaa"), Buffer.from("bbbc")]), + { maxBufferSize: 6 }, + ); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// The maxBufferSize error messages must name the collector and the limit value +// (kills StringLiteral -> "" on each `buffer exceeds maxBufferSize (...)`). +test(`${variant}: maxBufferSize errors carry collector name and limit`, async (_t) => { + const cases = [ + [streamToArray, ["aaa", "bbb", "ccc"], "streamToArray"], + [streamToString, ["aaa", "bbb", "ccc"], "streamToString"], + [streamToBuffer, [Buffer.from("aaaaaaa")], "streamToBuffer"], + ]; + for (const [fn, input, name] of cases) { + try { + await fn(createReadableStream(input), { maxBufferSize: 6 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes(name), `expected ${name} in: ${e.message}`); + ok(e.message.includes("6"), `expected limit 6 in: ${e.message}`); + } + } + try { + await streamToObject(createReadableStream([{ a: 1 }, { b: 2 }, { c: 3 }]), { + maxBufferSize: 2, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("streamToObject")); + ok(e.message.includes("2")); + } +}); + +// --- async-iterable path also enforces maxBufferSize boundary (covers the +// second copy of each guard in the for-await branch). --- +const asyncGen = (chunks) => + (async function* () { + for (const c of chunks) yield c; + })(); + +test(`${variant}: streamToArray async path enforces boundary precisely`, async (_t) => { + const out = await streamToArray(asyncGen(["aaa", "bbb"]), { + maxBufferSize: 6, + }); + deepStrictEqual(out, ["aaa", "bbb"]); + try { + await streamToArray(asyncGen(["aaa", "bbbc"]), { maxBufferSize: 6 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToString async path enforces boundary precisely`, async (_t) => { + const out = await streamToString(asyncGen(["aaa", "bbb"]), { + maxBufferSize: 6, + }); + strictEqual(out, "aaabbb"); + try { + await streamToString(asyncGen(["aaa", "bbbc"]), { maxBufferSize: 6 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToObject async path enforces boundary precisely`, async (_t) => { + const out = await streamToObject(asyncGen([{ a: 1 }, { b: 2 }, { c: 3 }]), { + maxBufferSize: 3, + }); + deepStrictEqual(out, { a: 1, b: 2, c: 3 }); + try { + await streamToObject(asyncGen([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]), { + maxBufferSize: 3, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToBuffer async path enforces boundary precisely`, async (_t) => { + const out = await streamToBuffer( + asyncGen([Buffer.from("aaa"), Buffer.from("bbb")]), + { maxBufferSize: 6 }, + ); + strictEqual(out.toString(), "aaabbb"); + try { + await streamToBuffer(asyncGen([Buffer.from("aaa"), Buffer.from("bbbc")]), { + maxBufferSize: 6, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// streamToObject async byteLength fallback (Uint8Array chunks count by bytes). +test(`${variant}: streamToObject byteLength fallback counts bytes`, async (_t) => { + // A typed array of 5 bytes -> size 5 > 4 must throw even in object mode. + const chunk = Uint8Array.from([1, 2, 3, 4, 5]); + try { + await streamToObject(createReadableStream([chunk]), { maxBufferSize: 4 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// The size fallback is `length ?? byteLength ?? N`, NOT `length && byteLength`. +// A chunk with NO `length` but a truthy `byteLength` must count by byteLength. +// Under the `&&` (LogicalOperator) mutant, `undefined && byteLength` is +// undefined, so the fallback collapses to N (1 or 0) and the limit is not hit. +// Use a plain object exposing only `byteLength` so `?.length` is undefined. +const byteLenChunk = (n, extra = {}) => ({ byteLength: n, ...extra }); + +test(`${variant}: streamToArray counts a byteLength-only chunk by its byteLength`, async (_t) => { + // .on path: size 5 > 4 must throw (kills `?? -> &&` at the .on guard). + try { + await streamToArray(createReadableStream([byteLenChunk(5)]), { + maxBufferSize: 4, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + // async path: same expectation. + try { + await streamToArray(asyncGen([byteLenChunk(5)]), { maxBufferSize: 4 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToObject counts a byteLength-only chunk by its byteLength`, async (_t) => { + try { + await streamToObject(createReadableStream([byteLenChunk(5, { a: 1 })]), { + maxBufferSize: 4, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + try { + await streamToObject(asyncGen([byteLenChunk(5, { a: 1 })]), { + maxBufferSize: 4, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToString counts a byteLength-only chunk by its byteLength`, async (_t) => { + // streamToString fallback is `?? 0`; a byteLength-only chunk of 5 still + // exceeds maxBufferSize 4 via byteLength (kills `?? -> &&`). + try { + await streamToString(createReadableStream([byteLenChunk(5)]), { + maxBufferSize: 4, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + try { + await streamToString(asyncGen([byteLenChunk(5)]), { maxBufferSize: 4 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// --- async path `?? N` size fallbacks when chunks have neither length nor +// byteLength (kills `?? 1` / `?? 0` in the for-await branch of each collector). --- + +test(`${variant}: streamToArray async path ?? 1 fallback counts no-size chunks`, async (_t) => { + // A plain object has no length/byteLength; ?? 1 makes it count as 1. + // 4 such chunks -> size 4 > 3 must throw (kills the ?? 1 deletion mutant). + try { + await streamToArray(asyncGen([{}, {}, {}, {}]), { maxBufferSize: 3 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + // 3 chunks -> size 3, boundary passes. + const out = await streamToArray(asyncGen([{}, {}, {}]), { maxBufferSize: 3 }); + strictEqual(out.length, 3); +}); + +test(`${variant}: streamToString async path ?? 0 fallback does not count no-size chunks`, async (_t) => { + // streamToString uses ?? 0 for chunks without length/byteLength. + // Even with maxBufferSize: 0, plain-object chunks must NOT trip the limit. + const out = await streamToString(asyncGen([{}, {}]), { maxBufferSize: 0 }); + strictEqual(typeof out, "string"); +}); + +test(`${variant}: streamToBuffer async path handles null-sentinel chunk`, async (_t) => { + // The async path of streamToBuffer wraps each chunk with fromSafe(...) ?? []. + // Yielding the NULL_SENTINEL symbol triggers fromSafe -> null -> [] -> empty Buffer. + const NULL_SENTINEL = Symbol.for("@datastream/null"); + async function* gen() { + yield "hi"; + yield NULL_SENTINEL; + } + const out = await streamToBuffer(gen()); + // Only "hi" contributes bytes; the null-sentinel chunk becomes an empty buffer. + strictEqual(out.toString(), "hi"); +}); + +// --- NULL_SENTINEL round-trip: a transform enqueue(null) flows through as null, +// AND a literal pushed null is treated as EOF (does not appear). --- +test(`${variant}: createReadableStream array null becomes a flowing null value`, async (_t) => { + // toSafe maps array `null` -> NULL_SENTINEL so it is not interpreted as EOF; + // the collector's fromSafe maps it back to null. + const out = await streamToArray(createReadableStream(["a", null, "b"])); + deepStrictEqual(out, ["a", null, "b"]); +}); + +// --- sanitizeObject: own-enumerable __proto__ key must be stripped. Use a +// literal own-enumerable key via Object.defineProperty so the +// `key === "__proto__"` guard is exercised through Object.keys. --- +test(`${variant}: streamToObject strips an own-enumerable __proto__ key`, async (_t) => { + const evil = {}; + Object.defineProperty(evil, "__proto__", { + value: { polluted: true }, + enumerable: true, + configurable: true, + writable: true, + }); + Object.defineProperty(evil, "safe", { + value: 1, + enumerable: true, + configurable: true, + writable: true, + }); + strictEqual(Object.keys(evil).includes("__proto__"), true); + const out = await streamToObject(asyncGen([evil])); + strictEqual(Object.hasOwn(out, "__proto__"), false); + strictEqual(out.safe, 1); + // Without the `key === "__proto__"` guard, `out["__proto__"] = ...` would + // replace out's prototype (out is a plain `{}`), leaking `polluted` and + // changing the prototype. The guard must keep a clean Object.prototype. + strictEqual(Object.getPrototypeOf(out), Object.prototype); + strictEqual(out.polluted, undefined); +}); + +// --- pipeline: lastStream index is length - 1 (kills `+ 1`). A single pure +// Readable needs an appended writable sink to terminate; with `+ 1` the index +// is undefined, isReadable(undefined) is false, no sink is appended, and +// pipelinePromise([readable]) rejects with "streams argument must be specified". +// This also kills the isReadable(lastStream) branch deletion. --- +test(`${variant}: pipeline appends a sink for a single pure readable`, async (_t) => { + const out = await withTimeout( + pipeline([createReadableStream(["a", "b", "c"])]), + ); + deepStrictEqual(out, {}); +}); + +// --- pipeline promise guard loop must actually iterate (kills loop `false`, +// `idx >= l`, block deletion, the `typeof ... then` check, and the index in the +// message). A promise at index 2 must be reported with that index. --- +test(`${variant}: pipeline reports the index of a promise-instead-of-stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream(), + Promise.resolve(createTransformStream()), + ]; + try { + await pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "Promise instead of stream passed in at index 2"); + } +}); + +// --- pipejoin forwards stream errors to the onError callback (kills onError +// block deletion / process.nextTick block deletion and the on("error") +// string mutant). --- +test(`${variant}: pipejoin error handler receives error on the right event`, async (_t) => { + let got; + const streams = [ + createReadableStream(["a"]), + createTransformStream(() => { + throw new Error("boom2"); + }), + ]; + pipejoin(streams, (e) => { + got = e.message; + }); + await timeout(50); + strictEqual(got, "boom2"); +}); + +// pipejoin's DEFAULT onError rethrows asynchronously via process.nextTick. +// Deleting that body would silently swallow stream errors. Capture the rethrow +// through an uncaughtException listener (kills the onError default-block and the +// process.nextTick block deletions). Listeners are restored when done. +test(`${variant}: pipejoin default onError rethrows the error`, async (_t) => { + let caught; + const handler = (e) => { + caught = e.message; + }; + // Temporarily own uncaughtException so the async rethrow is observable + // without crashing the test process. + const prior = process.listeners("uncaughtException"); + for (const l of prior) process.removeListener("uncaughtException", l); + process.on("uncaughtException", handler); + try { + const streams = [ + createReadableStream(["a"]), + createTransformStream(() => { + throw new Error("default-rethrow"); + }), + ]; + const joined = pipejoin(streams); // default onError + joined.on("error", () => {}); + joined.resume(); + await timeout(100); + strictEqual(caught, "default-rethrow"); + } finally { + process.removeListener("uncaughtException", handler); + for (const l of prior) process.on("uncaughtException", l); + } +}); + +// --- createReadableStream branch coverage --- + +// Array.isArray branch: array maps null -> sentinel so an embedded null is +// preserved as a value (raw null would be EOF). (kills branch deletion / +// `false` / `true`). +test(`${variant}: createReadableStream array branch preserves embedded null`, async (_t) => { + const out = await streamToArray(createReadableStream([null, "x"])); + deepStrictEqual(out, [null, "x"]); +}); + +// object-with-byteLength branch (ArrayBuffer) yields a single Uint8Array chunk. +test(`${variant}: createReadableStream routes ArrayBuffer to byte chunker`, async (_t) => { + const buf = new Uint8Array([9, 8, 7]).buffer; + const out = await streamToArray(createReadableStream(buf)); + strictEqual(out.length, 1); + deepStrictEqual(Array.from(out[0]), [9, 8, 7]); +}); + +// String routing splits by chunkSize via substring (kills MethodExpression +// yield input2 and the position arithmetic). +test(`${variant}: createReadableStream chunks strings by chunkSize via substring`, async (_t) => { + const input = "abcdefgh"; + const out = await streamToArray( + createReadableStream(input, { chunkSize: 3 }), + ); + deepStrictEqual(out, ["abc", "def", "gh"]); +}); + +test(`${variant}: createReadableStream string chunk boundary is exact`, async (_t) => { + // length 6, chunkSize 3 -> exactly 2 full chunks, no empty trailing chunk + // (kills while `<=` which would emit an extra empty "" at position===length). + const out = await streamToArray( + createReadableStream("abcdef", { chunkSize: 3 }), + ); + deepStrictEqual(out, ["abc", "def"]); +}); + +// chunkSize <= 0 must throw (kills `<= 0` -> `< 0`, `false`, and message ""). +test(`${variant}: createReadableStream rejects zero chunkSize for strings`, (_t) => { + try { + createReadableStream("abc", { chunkSize: 0 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("positive number")); + } +}); + +test(`${variant}: createReadableStream rejects negative chunkSize for strings`, (_t) => { + try { + createReadableStream("abc", { chunkSize: -1 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("positive number")); + } +}); + +test(`${variant}: createReadableStream rejects zero chunkSize for ArrayBuffer`, (_t) => { + try { + createReadableStream(new Uint8Array([1, 2, 3]).buffer, { chunkSize: 0 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("positive number")); + } +}); + +test(`${variant}: createReadableStream rejects negative chunkSize for ArrayBuffer`, (_t) => { + try { + createReadableStream(new Uint8Array([1, 2, 3]).buffer, { chunkSize: -2 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("positive number")); + } +}); + +// ArrayBuffer chunking by size (kills while/position mutants and the subarray +// window arithmetic in the arraybuffer iterator). +if (variant === "node") { + test(`${variant}: createReadableStream chunks ArrayBuffer by chunkSize`, async (_t) => { + const buf = new Uint8Array([1, 2, 3, 4, 5]).buffer; + const out = await streamToArray( + createReadableStream(buf, { chunkSize: 2 }), + ); + deepStrictEqual( + out.map((c) => Array.from(c)), + [[1, 2], [3, 4], [5]], + ); + }); +} + +// createReadableStream() push queue guard: the `chunk !== null` half means +// pushing null (EOF) is always allowed even past the limit. +if (variant === "node") { + test(`${variant}: createReadableStream allows null push even at queue limit`, async (_t) => { + const stream = createReadableStream(undefined, { highWaterMark: 2 }); + stream.push("a"); + stream.push("b"); + // At the limit; pushing null (EOF) must NOT throw (kills `chunk !== null` + // -> `true`, which would throw on the null push). + stream.push(null); + const out = await streamToArray(stream); + deepStrictEqual(out, ["a", "b"]); + }); + + test(`${variant}: createReadableStream allows pushing up to the limit`, async (_t) => { + const stream = createReadableStream(undefined, { highWaterMark: 3 }); + stream.push("a"); + stream.push("b"); + stream.push("c"); + stream.push(null); + const out = await streamToArray(stream); + deepStrictEqual(out, ["a", "b", "c"]); + }); +} + +// --- createPassThroughStream default passThrough is identity (kills the +// ArrowFunction mutant `() => undefined`). With no transform fn, chunks must +// flow through unchanged. --- +test(`${variant}: createPassThroughStream default identity passes chunks through`, async (_t) => { + const out = await streamToArray( + pipejoin([createReadableStream(["a", "b"]), createPassThroughStream()]), + ); + deepStrictEqual(out, ["a", "b"]); +}); + +// --- The thenable check must be a strict `typeof result.then === "function"`. +// A handler that returns a non-null, non-thenable value (e.g. a string) must NOT +// be treated as a promise: with `&& true` (or `!== "function"`, or `=== ""`) +// the code would call `result.then(...)` on a string and throw. Returning a +// plain value must pass cleanly. --- +test(`${variant}: createPassThroughStream tolerates a non-thenable return value`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(["a", "b"]), + createPassThroughStream((c) => `sync:${c}`), + ]), + ); + deepStrictEqual(out, ["a", "b"]); +}); + +test(`${variant}: createTransformStream tolerates a non-thenable return value`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(["a", "b"]), + createTransformStream((c, enqueue) => { + enqueue(c); + return "sync-return"; + }), + ]), + ); + deepStrictEqual(out, ["a", "b"]); +}); + +test(`${variant}: createWritableStream tolerates a non-thenable write return`, async (_t) => { + const seen = []; + await pipeline([ + createReadableStream(["a", "b"]), + createWritableStream((c) => { + seen.push(c); + return `sync:${c}`; + }), + ]); + deepStrictEqual(seen, ["a", "b"]); +}); + +test(`${variant}: createPassThroughStream flush tolerates a non-thenable return`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(["a"]), + createPassThroughStream( + (c) => c, + () => "flush-sync", + ), + ]), + ); + deepStrictEqual(out, ["a"]); +}); + +test(`${variant}: createTransformStream flush tolerates a non-thenable return`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(["a"]), + createTransformStream( + (c, enqueue) => enqueue(c), + () => "flush-sync", + ), + ]), + ); + deepStrictEqual(out, ["a"]); +}); + +test(`${variant}: createWritableStream final tolerates a non-thenable return`, async (_t) => { + const seen = []; + await pipeline([ + createReadableStream(["a"]), + createWritableStream( + (c) => seen.push(c), + () => "final-sync", + ), + ]); + deepStrictEqual(seen, ["a"]); +}); + +// --- async vs sync result detection in create*Stream. A function returning a +// thenable must be awaited BEFORE the chunk is pushed / callback runs. Ordering +// proves the promise branch (kills `=== "function"` -> `!== "function"` / +// `true` / `false` / "" in the thenable checks). --- +test(`${variant}: createPassThroughStream awaits a returned promise before continuing`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createPassThroughStream(async () => { + order.push("transform-start"); + await timeout(20); + order.push("transform-end"); + }), + createWritableStream((c) => { + order.push(`write-${c}`); + }), + ]; + await pipeline(streams); + deepStrictEqual(order, ["transform-start", "transform-end", "write-a"]); +}); + +test(`${variant}: createTransformStream awaits a returned promise before continuing`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createTransformStream(async (chunk, enqueue) => { + order.push("t-start"); + await timeout(20); + order.push("t-end"); + enqueue(chunk); + }), + createWritableStream((c) => order.push(`w-${c}`)), + ]; + await pipeline(streams); + deepStrictEqual(order, ["t-start", "t-end", "w-a"]); +}); + +test(`${variant}: createWritableStream awaits a returned promise before final`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createWritableStream( + async () => { + order.push("write-start"); + await timeout(20); + order.push("write-end"); + }, + () => { + order.push("final"); + }, + ), + ]; + await pipeline(streams); + deepStrictEqual(order, ["write-start", "write-end", "final"]); +}); + +test(`${variant}: createPassThroughStream awaits an async flush before finishing`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createPassThroughStream( + () => order.push("pass"), + async () => { + order.push("flush-start"); + await timeout(20); + order.push("flush-end"); + }, + ), + createWritableStream(), + ]; + await pipeline(streams); + deepStrictEqual(order, ["pass", "flush-start", "flush-end"]); +}); + +test(`${variant}: createTransformStream awaits an async flush before finishing`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createTransformStream( + (c, enqueue) => { + order.push("t"); + enqueue(c); + }, + async () => { + order.push("flush-start"); + await timeout(20); + order.push("flush-end"); + }, + ), + createWritableStream(), + ]; + await pipeline(streams); + deepStrictEqual(order, ["t", "flush-start", "flush-end"]); +}); + +// --- deepClone / deepEqual preserve the original error as `cause` (kills +// ObjectLiteral -> {} which drops the cause). --- +test(`${variant}: deepClone attaches the original error as cause`, (_t) => { + try { + deepClone({ fn: () => {} }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("clone chunk")); + ok(e.cause instanceof Error, "expected a cause error"); + } +}); + +test(`${variant}: deepEqual attaches the original error as cause`, (_t) => { + const a = {}; + a.self = a; + try { + deepEqual(a, a); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("stringify chunk")); + ok(e.cause instanceof Error, "expected a cause error"); + } +}); + +// --- shallowEqual: the `b == null` half of the guard. shallowEqual(obj, null) +// must be false; if `|| b == null` were dropped it would call Object.keys(null) +// and throw instead of returning false. --- +test(`${variant}: shallowEqual returns false when only b is null`, (_t) => { + strictEqual(shallowEqual({ a: 1 }, null), false); + strictEqual(shallowEqual({ a: 1 }, undefined), false); +}); + +// --- timeout abort: exact error shape (kills cause/code ObjectLiteral and +// StringLiteral mutants) for BOTH the already-aborted and abort-during paths. --- +test(`${variant}: timeout already-aborted rejects with AbortError cause code`, async (_t) => { + const controller = new AbortController(); + controller.abort(); + try { + await timeout(1000, { signal: controller.signal }); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "Aborted"); + deepStrictEqual(e.cause, { code: "AbortError" }); + strictEqual(e.cause.code, "AbortError"); + } +}); + +test(`${variant}: timeout abort-during rejects with AbortError cause code`, async (_t) => { + const controller = new AbortController(); + const promise = timeout(60_000, { signal: controller.signal }); + controller.abort(); + try { + await promise; + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "Aborted"); + deepStrictEqual(e.cause, { code: "AbortError" }); + strictEqual(e.cause.code, "AbortError"); + } +}); + +// timeout abort handler `settled` guard: after an abort, the later timer firing +// must NOT resolve the already-rejected promise (double-settle is a no-op). +test(`${variant}: timeout abort wins the race; later timer does not resolve`, async (_t) => { + const controller = new AbortController(); + const promise = timeout(30, { signal: controller.signal }); + controller.abort(); + let resolved = false; + let rejected = false; + promise.then( + () => { + resolved = true; + }, + () => { + rejected = true; + }, + ); + // Wait past the original 30ms timer to ensure its callback, if it ran, + // cannot flip an already-settled promise to resolved. + await timeout(60); + strictEqual(rejected, true); + strictEqual(resolved, false); +}); + +test(`${variant}: timeout removes its abort listener after resolving normally`, async (_t) => { + const controller = new AbortController(); + const { signal } = controller; + let removedAbort = 0; + const realRemove = signal.removeEventListener.bind(signal); + signal.removeEventListener = (type, ...rest) => { + if (type === "abort") removedAbort += 1; + return realRemove(type, ...rest); + }; + await timeout(10, { signal }); + // On normal resolution the listener registered for "abort" must be removed + // (kills the `if (signal) signal.removeEventListener("abort", ...)` and the + // "" string mutant in the resolve path). + strictEqual(removedAbort, 1); +}); + +test(`${variant}: timeout removes its abort listener on the abort path`, async (_t) => { + const controller = new AbortController(); + const { signal } = controller; + let removedAbort = 0; + const realRemove = signal.removeEventListener.bind(signal); + signal.removeEventListener = (type, ...rest) => { + if (type === "abort") removedAbort += 1; + return realRemove(type, ...rest); + }; + const promise = timeout(60_000, { signal }); + controller.abort(); + await promise.catch(() => {}); + // The abort handler must remove its own "abort" listener (kills the + // removeEventListener("abort", ...) -> "" string mutant on the abort path). + strictEqual(removedAbort, 1); +}); + +// --- backpressureGauge: duration arithmetic is a SUBTRACTION (Date.now() - +// start), so durations are small/non-negative, not gigantic sums (kills `+`). --- +test(`${variant}: backpressureGauge pause/resume duration is a small subtraction`, async (_t) => { + const transform = createTransformStream(); + const metrics = backpressureGauge({ transform }); + transform.emit("pause"); + await timeout(15); + transform.emit("resume"); + const { duration } = metrics.transform.timeline[0]; + ok(duration >= 0, `duration should be >=0, got ${duration}`); + // A summed (Date.now()+timestamp) value would be ~2*Date.now() (>1e12). + ok(duration < 1e6, `duration should be small, got ${duration}`); +}); + +// total recorded via 'finish'/'close' for writable sinks, AND the +// double-record guard means total.timestamp is set exactly once. +test(`${variant}: backpressureGauge records writable total exactly once`, async (_t) => { + const writable = createWritableStream(); + const metrics = backpressureGauge({ writable }); + // Fire all three terminal events; guard must record total exactly once. + writable.emit("finish"); + const first = metrics.writable.total.timestamp; + const firstDuration = metrics.writable.total.duration; + strictEqual(typeof first, "number"); + await timeout(30); + writable.emit("close"); + writable.emit("end"); + // Both timestamp AND duration must be unchanged by the later events. The + // recorded timestamp is always startTimestamp (so it can't move), but + // duration is Date.now()-start, so a re-record after a 30ms wait would + // bump duration. The `!= null` guard must prevent that (kills it -> false). + strictEqual(metrics.writable.total.timestamp, first); + strictEqual(metrics.writable.total.duration, firstDuration); + ok(metrics.writable.total.duration >= 0); + ok(metrics.writable.total.duration < 1e6); +}); + +// 'finish' and 'close' events are both wired (kills on("") string mutants): +// a writable that emits only 'finish' (no 'end') must still get a total. +test(`${variant}: backpressureGauge records total on the finish event`, async (_t) => { + const writable = createWritableStream(); + const metrics = backpressureGauge({ writable }); + writable.emit("finish"); + strictEqual(typeof metrics.writable.total.timestamp, "number"); +}); + +test(`${variant}: backpressureGauge records total on the close event`, async (_t) => { + const writable = createWritableStream(); + const metrics = backpressureGauge({ writable }); + writable.emit("close"); + strictEqual(typeof metrics.writable.total.timestamp, "number"); +}); + +// --- pipeline derives objectMode from a trailing readable's state and pushes a +// sink built from a (non-empty) streamOptions object. With the readable-last +// case, object chunks must drain without a non-buffer-chunk error. (covers the +// isReadable branch and the streamOptions object literal.) --- +test(`${variant}: pipeline drains an object-mode readable-last stream`, async (_t) => { + const streams = [ + createReadableStream([{ a: 1 }, { b: 2 }]), + createTransformStream((c, enqueue) => enqueue(c)), + ]; + const out = await withTimeout(pipeline(streams)); + deepStrictEqual(out, {}); +}); + +// --- pipeline passes the spread streamOptions object (not `{}`) to +// pipelinePromise for a readable-last pipeline. A signal supplied in +// streamOptions and aborted mid-flight must abort the pipeline. The +// ObjectLiteral -> `{}` mutant drops the signal so the pipeline runs to +// completion and resolves instead of rejecting. --- +if (variant === "node") { + test(`${variant}: pipeline forwards a streamOptions signal to the readable-last sink`, async (_t) => { + // A slow trailing readable so the abort lands while the pipeline runs. + let emitted = 0; + const slowReadable = new Readable({ + objectMode: true, + read() { + setTimeout(() => { + this.push(emitted < 10 ? String(emitted++) : null); + }, 20); + }, + }); + const controller = new AbortController(); + const pending = pipeline([slowReadable], { signal: controller.signal }); + setTimeout(() => controller.abort(), 30); + try { + await withTimeout(pending); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.code, "ABORT_ERR"); + } + }); +} + +// --- size accumulation `chunk?.length ?? chunk?.byteLength ?? N` is computed +// for every chunk. An `undefined` chunk leaves both optional chains nullish so +// the `?? N` fallback applies. Removing either `?.` (OptionalChaining mutant) +// makes `undefined.length` / `undefined.byteLength` throw, so a stream carrying +// an `undefined` chunk must still drain cleanly. Covers .on and async paths of +// every collector. --- +if (variant === "node") { + test(`${variant}: streamToArray tolerates an undefined chunk (.on + async paths)`, async (_t) => { + const onOut = await streamToArray(emitterStream([undefined, "a"])); + deepStrictEqual(onOut, [undefined, "a"]); + const asyncOut = await streamToArray(asyncGen([undefined, "a"])); + deepStrictEqual(asyncOut, [undefined, "a"]); + }); + + test(`${variant}: streamToString tolerates an undefined chunk (.on + async paths)`, async (_t) => { + const onOut = await streamToString(emitterStream([undefined, "a"])); + strictEqual(onOut, "a"); + const asyncOut = await streamToString(asyncGen([undefined, "a"])); + strictEqual(asyncOut, "a"); + }); + + test(`${variant}: streamToObject tolerates an undefined chunk (.on + async paths)`, async (_t) => { + const onOut = await streamToObject(emitterStream([undefined, { a: 1 }])); + deepStrictEqual(onOut, { a: 1 }); + const asyncOut = await streamToObject(asyncGen([undefined, { a: 1 }])); + deepStrictEqual(asyncOut, { a: 1 }); + }); +} + +// --- createReadableStream input dispatch. An iterable WITHOUT a `.map` method +// (a Set) must reach the `Readable.from(input)` branch. The Array.isArray -> +// `true` mutant would call `set.map(...)`, which throws because Set has no map. --- +test(`${variant}: createReadableStream streams a non-array iterable (Set) input`, async (_t) => { + const input = new Set(["a", "b", "c"]); + const streams = [createReadableStream(input)]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["a", "b", "c"]); +}); + +// --- createReadableStreamFromString / ...FromArrayBuffer read chunkSize via +// `streamOptions?.chunkSize`. Calling them directly with a `null` options object +// must fall back to the default chunkSize; the OptionalChaining mutant +// (`streamOptions.chunkSize`) would throw on `null.chunkSize`. --- +test(`${variant}: createReadableStreamFromString tolerates null streamOptions`, async (_t) => { + const mod = await import("@datastream/core"); + const stream = mod.createReadableStreamFromString("abcdef", null); + const output = await streamToString(stream); + strictEqual(output, "abcdef"); +}); + +test(`${variant}: createReadableStreamFromArrayBuffer tolerates null streamOptions`, async (_t) => { + const mod = await import("@datastream/core"); + const input = new Uint8Array([1, 2, 3, 4, 5]).buffer; + const stream = mod.createReadableStreamFromArrayBuffer(input, null); + const output = await streamToArray(stream); + deepStrictEqual(Array.from(output[0]), [1, 2, 3, 4, 5]); +}); + +// --- string iterator loops `while (position < length)`. With a chunkSize that +// divides the input length exactly, the `<=` (EqualityOperator) mutant runs one +// extra iteration and yields a trailing empty-string chunk. Pin the exact chunk +// list so a phantom "" chunk fails the assertion. --- +test(`${variant}: createReadableStreamFromString stops exactly at the end (no trailing empty chunk)`, async (_t) => { + const mod = await import("@datastream/core"); + // length 8, chunkSize 4 -> exactly 2 chunks, no remainder. + const stream = mod.createReadableStreamFromString("abcdefgh", { + chunkSize: 4, + }); + const output = await streamToArray(stream); + deepStrictEqual(output, ["abcd", "efgh"]); +}); + +// --- ArrayBuffer iterator loops `while (position < length)`. With a chunkSize +// dividing byteLength exactly, the `<=` mutant runs one extra iteration and +// yields a trailing empty (zero-length) chunk. Pin exactly 2 chunks. --- +test(`${variant}: createReadableStreamFromArrayBuffer stops exactly at the end (no trailing empty chunk)`, async (_t) => { + const mod = await import("@datastream/core"); + // 8 bytes, chunkSize 4 -> exactly 2 chunks of 4 bytes, no remainder. + const input = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).buffer; + const stream = mod.createReadableStreamFromArrayBuffer(input, { + chunkSize: 4, + }); + const output = await streamToArray(stream); + strictEqual(output.length, 2); + deepStrictEqual(Array.from(output[0]), [1, 2, 3, 4]); + deepStrictEqual(Array.from(output[1]), [5, 6, 7, 8]); +}); + +// --- createPassThroughStream default passThrough is `(chunk) => chunk`. The +// default's return value feeds the thenable detection: when a chunk is itself a +// rejecting thenable, the default returns it, the stream awaits it, and the +// rejection surfaces as a stream error. The ArrowFunction mutant +// (`() => undefined`) returns undefined, skips the await, and the pipeline +// would resolve instead of rejecting. --- +test(`${variant}: createPassThroughStream default forwards the chunk's own thenable (rejection surfaces)`, async (_t) => { + // A thenable object whose `then` rejects. Built via a computed key so the + // linter's no-thenable-literal rule is not tripped; it is intentional here. + const thenKey = "then"; + const rejecting = { + [thenKey](_resolve, reject) { + reject(new Error("thenable-rejected")); + }, + }; + // Push the thenable directly (Readable.from would await/reject it before the + // transform); piping hands the raw thenable to the default passThrough, whose + // returned value (the chunk) is then awaited. + const source = createReadableStream(); + process.nextTick(() => { + source.push(rejecting); + source.push(null); + }); + const streams = [source, createPassThroughStream()]; + try { + await withTimeout(pipeline(streams)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "thenable-rejected"); + } +}); + +// --- createWritableStream final handler awaits a returned thenable. An async +// `final` that REJECTS must propagate as a pipeline error. The thenable-check +// mutants (`if (false)`, `typeof ... === ""`) would take the else branch and +// resolve, swallowing the rejection. --- +test(`${variant}: createWritableStream propagates a rejecting async final`, async (_t) => { + const streams = [ + createReadableStream(["a", "b", "c"]), + createWritableStream( + () => {}, + async () => { + throw new Error("final-rejected"); + }, + ), + ]; + try { + await withTimeout(pipeline(streams)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "final-rejected"); + } +}); + +// =========================================================================== +// *** Stream-leak resilience *** +// Legacy `.pipe()` only tears a chain down on `finish`; when a stream errors +// or a consumer closes early it leaves the upstream source running, producing +// into a dead pipeline (the classic Node.js stream leak). pipejoin must mirror +// what pipeline()/pipelinePromise does internally: destroy EVERY stream on the +// first error so nothing is left running. pipeline() (pipelinePromise) already +// does this; these tests pin both behaviours so neither can regress. +// =========================================================================== + +// pipejoin's teardown is build-agnostic: the node build loads under both the +// node and webstream conditions, so this runs under every variant to keep the +// teardown `settled` re-entry guard and the already-destroyed skip covered in +// both builds (the variant-gated tests below would leave those branches uncovered +// under webstream). +test(`${variant}: pipejoin teardown is idempotent and skips already-destroyed streams`, async (_t) => { + const source = createReadableStream(["a", "b", "c"]); + const failing = createTransformStream(() => { + throw new Error("teardown-failure"); + }); + const sink = createWritableStream(); + // Count how often the already-destroyed erroring stream gets destroy()ed: Node + // tears `failing` down on throw, so teardown's loop must SKIP it (the + // `!stream.destroyed` guard) — it must end at exactly one destroy, not two. + let failingDestroyCalls = 0; + const failingDestroy = failing.destroy.bind(failing); + failing.destroy = (error) => { + failingDestroyCalls++; + return failingDestroy(error); + }; + // Destroying the streams re-emits 'error', re-entering teardown; the `settled` + // guard must short-circuit so onError fires exactly once, not once per stream. + let onErrorCalls = 0; + pipejoin([source, failing, sink], () => { + onErrorCalls++; + }); + await timeout(50); + strictEqual( + onErrorCalls, + 1, + "onError must fire exactly once (settled guard)", + ); + strictEqual( + failingDestroyCalls, + 1, + "already-destroyed stream must not be re-destroyed", + ); + strictEqual(source.destroyed, true); + strictEqual(failing.destroyed, true); + strictEqual(sink.destroyed, true); +}); + +if (variant === "node") { + // A mid-chain error must destroy the upstream source AND the downstream sink, + // not just the stream that threw. With bare `.pipe()` the source survives and + // keeps producing — the leak this guards against. + test(`${variant}: pipejoin destroys every stream when one errors (no upstream leak)`, async (_t) => { + const source = createReadableStream(["a", "b", "c"]); + const failing = createTransformStream(() => { + throw new Error("mid-failure"); + }); + const sink = createWritableStream(); + // Swallow the error; we are asserting on teardown, not propagation here. + pipejoin([source, failing, sink], () => {}); + await timeout(50); + strictEqual(source.destroyed, true, "source must be destroyed"); + strictEqual(failing.destroyed, true, "failing transform must be destroyed"); + strictEqual(sink.destroyed, true, "downstream sink must be destroyed"); + }); + + // The error must still reach a collector consuming the joined stream, and the + // upstream source must be torn down rather than left producing. + test(`${variant}: pipejoin surfaces the error to a collector and tears the source down`, async (_t) => { + const source = createReadableStream(["a", "b", "c"]); + const failing = createTransformStream((chunk, enqueue) => { + if (chunk === "b") throw new Error("collector-failure"); + enqueue(chunk); + }); + const joined = pipejoin([source, failing], () => {}); + try { + await withTimeout(streamToArray(joined)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "collector-failure"); + } + strictEqual(source.destroyed, true, "source must be destroyed on error"); + }); + + // A consumer that stops reading early (break out of the async iterator) must + // not leave the source running: the iterator protocol destroys the readable, + // and pipejoin's teardown must propagate that to every upstream stream. + test(`${variant}: pipejoin tears the source down when the consumer stops early`, async (_t) => { + const source = createReadableStream(["a", "b", "c", "d", "e"]); + const passThrough = createPassThroughStream((c) => c); + const joined = pipejoin([source, passThrough], () => {}); + for await (const chunk of joined) { + if (chunk === "b") break; // early exit -> iterator destroys `joined` + } + await timeout(50); + strictEqual(joined.destroyed, true, "joined stream must be destroyed"); + strictEqual( + source.destroyed, + true, + "source must be destroyed on early exit", + ); + }); + + // Characterization: pipeline() (pipelinePromise) already destroys the whole + // chain on a mid-stream error. Pin it so the resilient teardown can't regress. + test(`${variant}: pipeline destroys the source when a transform errors`, async (_t) => { + const source = createReadableStream(["a", "b", "c"]); + const streams = [ + source, + createTransformStream(() => { + throw new Error("pipeline-mid-failure"); + }), + ]; + try { + await withTimeout(pipeline(streams)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "pipeline-mid-failure"); + } + strictEqual(source.destroyed, true, "pipeline must destroy the source"); + }); +} diff --git a/packages/core/index.web.js b/packages/core/index.web.js index 16b5887..dd6edf9 100644 --- a/packages/core/index.web.js +++ b/packages/core/index.web.js @@ -3,9 +3,15 @@ /* global ReadableStream, TransformStream, WritableStream */ export const pipeline = async (streams, streamOptions = {}) => { + // Work on a copy so appending the terminal writable doesn't mutate the + // caller's array. + streams = [...streams]; // Ensure stream ends with only writable const lastStream = streams[streams.length - 1]; if (isReadable(lastStream)) { + // Web Streams have no objectMode flag, so (unlike the Node build, which + // derives objectMode from the source) the auto-appended terminal sink + // intentionally just forwards the caller's streamOptions. streams.push(createWritableStream(() => {}, streamOptions)); } @@ -76,6 +82,10 @@ export const streamToObject = async (stream, { maxBufferSize } = {}) => { export const streamToString = async (stream, { maxBufferSize } = {}) => { const chunks = []; let size = 0; + // A streaming TextDecoder so multibyte sequences split across byte chunks + // decode correctly. Mirrors Node's Buffer.concat(...).toString() semantics: + // byte chunks are decoded as UTF-8, anything else is String()-ified. + let decoder; for await (const chunk of stream) { if (maxBufferSize != null) { size += chunk?.length ?? chunk?.byteLength ?? 0; @@ -85,16 +95,58 @@ export const streamToString = async (stream, { maxBufferSize } = {}) => { ); } } - chunks.push(chunk); + if (ArrayBuffer.isView(chunk) || chunk instanceof ArrayBuffer) { + // Without { stream: true } a typed array of raw bytes would be + // coerced by Array.join into comma-separated decimal byte codes. + decoder ??= new TextDecoder(); + chunks.push(decoder.decode(chunk, { stream: true })); + } else { + chunks.push(`${chunk}`); + } } + // Flush any bytes buffered by the streaming decoder. + if (decoder) chunks.push(decoder.decode()); return chunks.join(""); }; +// Browser parity for the Node streamToBuffer. There is no Buffer in the web +// runtime, so this returns a concatenated Uint8Array (Node's Buffer is itself +// a Uint8Array, so byte consumers behave identically across builds). +export const streamToBuffer = async (stream, { maxBufferSize } = {}) => { + const value = []; + let size = 0; + for await (const chunk of stream) { + const buf = + chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + if (maxBufferSize != null) { + size += buf.byteLength; + if (size > maxBufferSize) { + throw new Error( + `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, + ); + } + } + value.push(buf); + } + const total = value.reduce((n, b) => n + b.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const b of value) { + out.set(b, offset); + offset += b.byteLength; + } + return out; +}; + export const isReadable = (stream) => { + // Short-circuit non-objects so the `.readable` access can't throw on + // null/undefined/primitives (Node's instanceof check safely returns false). + if (stream == null || typeof stream !== "object") return false; return stream instanceof ReadableStream || !!stream.readable; }; export const isWritable = (stream) => { + if (stream == null || typeof stream !== "object") return false; return stream instanceof WritableStream || !!stream.writable; }; @@ -125,13 +177,29 @@ export const createReadableStream = (input, streamOptions = {}) => { if (chunkSize <= 0) throw new Error("chunkSize must be a positive number"); const queued = []; const { readableStrategy } = makeOptions(streamOptions); + // Shared drain logic for start() and pull() so both paths treat a queued + // null identically as EOF. Returns false once it has closed the stream. + const drainQueued = (controller) => { + while (queued.length) { + const chunk = queued.shift(); + if (chunk === null) { + controller.close(); + return false; + } + controller.enqueue(chunk); + } + return true; + }; const stream = new ReadableStream( { async start(controller) { - while (queued.length) { - const chunk = queued.shift(); - controller.enqueue(chunk); - } + // Drain pre-start pushes using the SAME null-as-EOF semantics as + // pull(); the WHATWG spec allows an async start(), so a terminating + // null queued before start runs must still close the stream. + if (!drainQueued(controller)) return; + // No input => manual-push mode (mirrors the node build's undefined + // branch): leave the stream open for stream.push()/pull(). + if (input === undefined) return; if (typeof input === "string") { let position = 0; const length = input.length; @@ -146,8 +214,18 @@ export const createReadableStream = (input, streamOptions = {}) => { controller.enqueue(input[i]); } controller.close(); - } else if (typeof input === "object" && input.byteLength) { - const bytes = new Uint8Array(input.buffer ?? input); + } else if ( + input != null && + typeof input === "object" && + input.byteLength + ) { + // Honor the view's byteOffset/byteLength; constructing + // new Uint8Array(input.buffer) over the whole backing buffer would + // leak adjacent heap (pooled Buffers / .subarray() views) and + // diverge from the Node build. + const bytes = ArrayBuffer.isView(input) + ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) + : new Uint8Array(input); let position = 0; const length = bytes.byteLength; while (position < length) { @@ -155,23 +233,36 @@ export const createReadableStream = (input, streamOptions = {}) => { position += chunkSize; } controller.close(); - } else if (["function", "object"].includes(typeof input)) { + } else if ( + input != null && + ["function", "object"].includes(typeof input) && + typeof input[Symbol.asyncIterator] === "function" + ) { for await (const chunk of input) { controller.enqueue(chunk); } controller.close(); - } - }, - pull(controller) { - while (queued.length) { - const chunk = queued.shift(); - if (chunk === null) { - controller.close(); - } else { + } else if ( + input != null && + typeof input === "object" && + typeof input[Symbol.iterator] === "function" + ) { + for (const chunk of input) { controller.enqueue(chunk); } + controller.close(); + } else { + // number/boolean/symbol/null/non-iterable object: a missing branch + // previously left the stream open forever (a silent hang). Error + // promptly to match Node's immediate throw. + controller.error( + new TypeError("createReadableStream: unsupported input type"), + ); } }, + pull(controller) { + drainQueued(controller); + }, }, readableStrategy, ); @@ -194,27 +285,50 @@ export const createPassThroughStream = (passThrough, flush, streamOptions) => { } const { signal } = streamOptions ?? {}; const { writableStrategy, readableStrategy } = makeOptions(streamOptions); + // Track the abort listener so we can remove it once the stream settles; + // otherwise a shared signal accumulates a listener per constructed stream. + // cleanup() is idempotent and is invoked from EVERY terminal path (clean + // flush, downstream cancel, and a thrown transform/flush callback), not just + // the clean flush — otherwise an errored/cancelled stream leaks its listener. + let onAbort; + const cleanup = () => { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }; return new TransformStream( { start(controller) { if (signal) { - signal.addEventListener("abort", () => { + onAbort = () => controller.error( signal.reason ?? new DOMException("Aborted", "AbortError"), ); - }); + signal.addEventListener("abort", onAbort, { once: true }); } }, async transform(chunk, controller) { - await passThrough(chunk); + try { + await passThrough(chunk); + } catch (e) { + cleanup(); + throw e; + } controller.enqueue(chunk); }, async flush(controller) { + cleanup(); if (flush) { await flush(); } controller.terminate(); }, + // Called when the readable side is cancelled or the writable side is + // aborted/errored (e.g. a downstream sink errors). + cancel() { + cleanup(); + }, }, writableStrategy, readableStrategy, @@ -229,24 +343,39 @@ export const createTransformStream = (transform, flush, streamOptions) => { } const { signal } = streamOptions ?? {}; const { writableStrategy, readableStrategy } = makeOptions(streamOptions); + // See createPassThroughStream for why cleanup() is wired into every terminal + // path rather than only the clean flush. + let onAbort; + const cleanup = () => { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }; return new TransformStream( { start(controller) { if (signal) { - signal.addEventListener("abort", () => { + onAbort = () => controller.error( signal.reason ?? new DOMException("Aborted", "AbortError"), ); - }); + signal.addEventListener("abort", onAbort, { once: true }); } }, async transform(chunk, controller) { const enqueue = (chunk) => { controller.enqueue(chunk); }; - await transform(chunk, enqueue); + try { + await transform(chunk, enqueue); + } catch (e) { + cleanup(); + throw e; + } }, async flush(controller) { + cleanup(); if (flush) { const enqueue = (chunk) => { controller.enqueue(chunk); @@ -255,6 +384,9 @@ export const createTransformStream = (transform, flush, streamOptions) => { } controller.terminate(); }, + cancel() { + cleanup(); + }, }, writableStrategy, readableStrategy, @@ -269,25 +401,45 @@ export const createWritableStream = (write, close, streamOptions) => { } const { signal } = streamOptions ?? {}; const { writableStrategy } = makeOptions(streamOptions); + // See createPassThroughStream for why cleanup() is wired into every terminal + // path (clean close, abort, and a thrown write/close callback). + let onAbort; + const cleanup = () => { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }; return new WritableStream( { start(controller) { if (signal) { - signal.addEventListener("abort", () => { + onAbort = () => controller.error( signal.reason ?? new DOMException("Aborted", "AbortError"), ); - }); + signal.addEventListener("abort", onAbort, { once: true }); } }, async write(chunk) { - await write(chunk); + try { + await write(chunk); + } catch (e) { + cleanup(); + throw e; + } }, async close() { + cleanup(); if (close) { await close(); } }, + // Called when the stream is aborted (signal fires or a downstream error + // propagates), where close() would never run. + abort() { + cleanup(); + }, }, writableStrategy, ); diff --git a/packages/core/package.json b/packages/core/package.json index 7c9e3dc..4bdd7f3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/core", - "version": "0.5.0", + "version": "0.6.1", "description": "Stream creation utilities and pipeline functions for Web Streams API and Node.js streams", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -61,6 +62,6 @@ "homepage": "https://datastream.js.org", "dependencies": {}, "devDependencies": { - "@datastream/object": "0.5.0" + "@datastream/object": "0.6.1" } } diff --git a/packages/csv/README.md b/packages/csv/README.md index 84c4670..700fd93 100644 --- a/packages/csv/README.md +++ b/packages/csv/README.md @@ -1,6 +1,6 @@

<datastream> `csv`

- datastream logo + datastream logo

CSV parsing and formatting streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/csv/index.js b/packages/csv/index.js index 831c843..132cf28 100644 --- a/packages/csv/index.js +++ b/packages/csv/index.js @@ -5,10 +5,7 @@ import { createTransformStream, resolveLazy, } from "@datastream/core"; -import { - objectFromEntriesStream, - objectToEntriesStream, -} from "@datastream/object"; +import { objectToEntriesStream } from "@datastream/object"; const comma = ","; const quote = "'"; @@ -34,9 +31,79 @@ const stripBOM = (str) => { return str.charCodeAt(0) === 0xfeff ? str.slice(1) : str; }; +// True when the quote at `idx` is escaped — i.e. preceded by an ODD run of +// escapeChar (scanning no further back than `lowerBound`). A "not found" index +// of -1 yields false (the lookback inspects nothing), so callers can use this +// directly as a loop condition without a separate -1 check. +const quoteIsEscaped = (text, idx, lowerBound, escapeCode) => { + let escaped = false; + let k = idx - 1; + while (k >= lowerBound && text.charCodeAt(k) === escapeCode) { + escaped = !escaped; + k--; + } + return escaped; +}; + +// Quote/escape-aware scan for the end of the first record (row). Returns the +// index of the newline that terminates row 0, or -1 if no complete row is +// present. Newlines inside quoted fields are skipped so a quoted newline does +// not split the row. Mirrors the field-start quoting rules of the parser. +const findRowEnd = ( + text, + delimiterChar, + newlineChar, + quoteChar, + escapeChar, +) => { + const quoteCode = quoteChar.charCodeAt(0); + const escapeCode = escapeChar.charCodeAt(0); + const delimiterLength = delimiterChar.length; + let pos = 0; + let nextNl = text.indexOf(newlineChar, 0); + for (;;) { + // `pos` is always a field start here (it advances only past a closing quote + // or a delimiter), so a quote at `pos` always opens a quoted field. + if (text.charCodeAt(pos) === quoteCode) { + // Quoted field: find the matching closing quote with indexOf. A quote is + // escaped (does not close the field) only when the run of escapeChar + // immediately before it is odd. When escapeChar === quoteChar this run + // counts the doubled "" quotes, so one algorithm covers both conventions. + // Only the parity of quotes matters for locating the row-terminating + // newline, so this matches the parser's field-close position. + const contentStart = pos + 1; + let closeQ = text.indexOf(quoteChar, contentStart); + // Skip escaped quotes; a "not found" (-1) is treated as not-escaped and + // ends the loop. + while (quoteIsEscaped(text, closeQ, contentStart, escapeCode)) { + closeQ = text.indexOf(quoteChar, closeQ + 1); + } + // Unterminated quote → no complete row in this buffer. + if (closeQ === -1) return -1; + pos = closeQ + 1; + continue; + } + if (nextNl !== -1 && nextNl < pos) { + nextNl = text.indexOf(newlineChar, pos); + } + const nextDelim = text.indexOf(delimiterChar, pos); + // Advance past a delimiter that falls at or before the row's newline (a + // delimiter that is a prefix of the newline wins the tie). When there is no + // newline, nextNl is -1 and `nextDelim <= -1` is false, so the loop returns + // -1 (an incomplete row). Otherwise the newline ends row 0. + if (nextDelim !== -1 && nextDelim <= nextNl) { + pos = nextDelim + delimiterLength; + continue; + } + return nextNl; + } +}; + export const csvDetectDelimitersStream = (options = {}, streamOptions = {}) => { const { - chunkSize = 1024, // 1KB + // chunkSize is accepted for compatibility; detect() already waits for a + // complete first line, so no byte threshold is needed. + chunkSize: _chunkSize, resultKey, } = options; @@ -66,9 +133,41 @@ export const csvDetectDelimitersStream = (options = {}, streamOptions = {}) => { (delimiter) => headerString.indexOf(delimiter) > -1, ) ?? defaultDelimiterChar; + // A char is only the quote char when it actually BRACKETS a field: it + // opens at a field-start (text start, or right after a delimiter or a + // newline) AND a matching quote closes the field right before the next + // delimiter/newline/end. A bare apostrophe/quote inside ordinary data + // (e.g. "'twas the night", which opens but never closes a field) must + // NOT be mistaken for the quote char. + const delimiterChar = value.delimiterChar; + const cr = carageReturn.charCodeAt(0); + const lf = lineFeed.charCodeAt(0); + const isFieldStart = (i) => + i === 0 || + text.startsWith(delimiterChar, i - delimiterChar.length) || + text.charCodeAt(i - 1) === cr || + text.charCodeAt(i - 1) === lf; + const isFieldEnd = (i) => + i >= text.length || + text.startsWith(delimiterChar, i) || + text.charCodeAt(i) === cr || + text.charCodeAt(i) === lf; value.quoteChar = - detectQuoteChars.find((delimiter) => text.indexOf(delimiter) > -1) ?? - defaultQuoteChar; + detectQuoteChars.find((candidate) => { + let i = text.indexOf(candidate); + while (i > -1) { + if (isFieldStart(i)) { + // Look for a closing quote that ends the field. + let close = text.indexOf(candidate, i + 1); + while (close > -1) { + if (isFieldEnd(close + 1)) return true; + close = text.indexOf(candidate, close + 1); + } + } + i = text.indexOf(candidate, i + 1); + } + return false; + }) ?? defaultQuoteChar; value.escapeChar = value.quoteChar; return true; }; @@ -79,18 +178,21 @@ export const csvDetectDelimitersStream = (options = {}, streamOptions = {}) => { return; } buffer += chunk; - if (buffer.length >= chunkSize && detect(buffer)) { + // detect() returns false until the buffer holds a complete first line, so + // it can be attempted on every chunk without a size threshold. + if (detect(buffer)) { detected = true; enqueue(buffer); - buffer = ""; + // `buffer` is not read again once detected is set. } }; const flush = (enqueue) => { if (!detected && buffer.length > 0) { + // Detect from whatever was buffered (may be a partial line) and emit it. detect(buffer); enqueue(buffer); - buffer = ""; + // End of stream; `buffer` is not read again. } }; @@ -101,7 +203,9 @@ export const csvDetectDelimitersStream = (options = {}, streamOptions = {}) => { export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { let { - chunkSize = 1024, // 1KB + // chunkSize is accepted for compatibility; the header is processed as soon + // as a complete first row is buffered. + chunkSize: _chunkSize, parser, delimiterChar, newlineChar, @@ -110,45 +214,39 @@ export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { resultKey, } = options; - const value = { - header: [], - }; + // `header` is always assigned by processBuffer (which runs at the latest on + // flush) before the stream's result() is read. + const value = {}; let buffer = ""; let headerDetected = false; - const processBuffer = (enqueue) => { + const resolveOptions = () => { delimiterChar = resolveLazy(delimiterChar) ?? defaultDelimiterChar; newlineChar = resolveLazy(newlineChar) ?? defaultNewlineChar; quoteChar = resolveLazy(quoteChar) ?? defaultQuoteChar; escapeChar = resolveLazy(escapeChar) ?? quoteChar; + }; + + // Returns the offset of the row-0 terminator within the (BOM-stripped) buffer, + // or -1 if a complete header row is not yet present. + const headerRowEnd = () => + findRowEnd( + stripBOM(buffer), + delimiterChar, + newlineChar, + quoteChar, + escapeChar, + ); + const processBuffer = (enqueue, headerEndOfRow) => { const text = stripBOM(buffer); - buffer = ""; + // `buffer` is not read again once headerDetected is set, so it is left as-is. headerDetected = true; - const headerEndOfRow = text.indexOf(newlineChar); - if (headerEndOfRow === -1) { - // Entire input is header, no data rows - const parserFn = parser ?? csvQuotedParser; - const result = parserFn( - text, - { - delimiterChar, - newlineChar, - quoteChar, - escapeChar, - numCols: 0, - idx: 0, - }, - true, - ); - value.header = result.rows[0] ?? []; - return; - } - - const headerChunk = text.slice(0, headerEndOfRow); const parserFn = parser ?? csvQuotedParser; + const headerChunk = + headerEndOfRow === -1 ? text : text.slice(0, headerEndOfRow); const result = parserFn( headerChunk, { @@ -163,6 +261,11 @@ export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { ); value.header = result.rows[0] ?? []; + if (headerEndOfRow === -1) { + // Entire input is header, no data rows + return; + } + const rest = text.slice(headerEndOfRow + newlineChar.length); if (rest.length > 0) { enqueue(rest); @@ -175,14 +278,21 @@ export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { return; } buffer += chunk; - if (buffer.length >= chunkSize) { - processBuffer(enqueue); + resolveOptions(); + // Process as soon as a complete header row is buffered (a quoted newline in + // the header does not count); otherwise keep buffering. + const headerEndOfRow = headerRowEnd(); + if (headerEndOfRow !== -1) { + processBuffer(enqueue, headerEndOfRow); } }; const flush = (enqueue) => { - if (!headerDetected && buffer.length > 0) { - processBuffer(enqueue); + // Whatever remains (possibly a partial header row, or nothing) is finalized + // here. On empty input this yields an empty header and emits nothing. + if (!headerDetected) { + resolveOptions(); + processBuffer(enqueue, headerRowEnd()); } }; @@ -195,19 +305,35 @@ export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { // Both return { rows: string[][], tail: string, numCols: number, idx: number, errors?: {} } // Options can include pre-computed char codes (from csvSteamifyParser) or raw config strings. +// Inverse of csvFormatStream's custom-escape encoding (escapeChar !== quoteChar): +// the formatter escapes escapeChar -> escapeChar+escapeChar and quoteChar -> +// escapeChar+quoteChar. Reverse both in a single left-to-right pass so an +// escapeChar consumes the following char literally (handling escaped escapes +// and escaped quotes together), keeping format/parse a faithful round-trip. +const unescapeCustom = (text, escapeChar) => { + let out = ""; + let start = 0; + let i = text.indexOf(escapeChar, start); + // Each escapeChar consumes the following character literally. A trailing + // escapeChar with no following character (i + 1 === length) is kept as-is. + while (i !== -1 && i + 1 < text.length) { + out += text.substring(start, i) + text[i + 1]; + start = i + 2; + i = text.indexOf(escapeChar, start); + } + return out + text.substring(start); +}; + // Internal hot-path parser. Writes results directly to ctx and calls enqueue(fields) per row. // ctx must have all pre-computed char codes + numCols, idx, tail, errors fields. const csvParseInline = (text, ctx, isFlushing, enqueue) => { - const delimiterCharCode = ctx.delimiterCharCode; const delimiterChar = ctx.delimiterChar; const delimiterCharLength = ctx.delimiterCharLength; - const delimiterCharSingle = ctx.delimiterCharSingle; - const newlineCharCode = ctx.newlineCharCode; - const newlineCharSingle = ctx.newlineCharSingle; const newlineChar = ctx.newlineChar; const newlineCharLength = ctx.newlineCharLength; const quoteCharCode = ctx.quoteCharCode; const quoteChar = ctx.quoteChar; + const escapeChar = ctx.escapeChar; const escapeCharCode = ctx.escapeCharCode; const escapeIsQuote = ctx.escapeIsQuote; const escapedQuote = ctx.escapedQuote; @@ -220,35 +346,36 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { let rowStart = 0; let fieldStart = 0; - let rowTpl = numCols > 0 ? Array(numCols).fill("") : null; - let fields = rowTpl ? rowTpl.slice() : []; - let fi = 0; + // Each row is built by pushing fields in order, so fields.length always + // equals the field count — short rows simply have fewer entries. + let fields = []; let pos = 0; let lastWasDelimiter = false; + let nextNl = text.indexOf(newlineChar, 0); + + // Called at most once per invocation (each unterminated-quote branch returns + // immediately afterwards), so the error map and entry are created fresh here. const trackError = (id, message) => { - if (errors === null) errors = {}; - if (!errors[id]) errors[id] = { id, message, idx: [] }; - errors[id].idx.push(idx); + errors = { [id]: { id, message, idx: [idx] } }; }; - outer: while (pos < len) { - if (text.charCodeAt(pos) === quoteCharCode && pos === fieldStart) { + while (pos < len) { + // The outer loop is only (re)entered at a field start, so a quote here + // always opens a quoted field (mid-field quotes are consumed by the + // unquoted scan below and never reach this check). + if (text.charCodeAt(pos) === quoteCharCode) { // === QUOTED FIELD === lastWasDelimiter = false; pos++; const contentStart = pos; if (escapeIsQuote) { - // Find closing quote using indexOf, skipping escaped "" pairs + // Find the closing quote with indexOf, skipping escaped "" pairs. let closeQ = text.indexOf(quoteChar, pos); - let hasEscapes = false; - while ( - closeQ !== -1 && - closeQ + 1 < len && - text.charCodeAt(closeQ + 1) === quoteCharCode - ) { - hasEscapes = true; + let hadEscaped = false; + while (closeQ !== -1 && text.charCodeAt(closeQ + 1) === quoteCharCode) { + hadEscaped = true; closeQ = text.indexOf(quoteChar, closeQ + 2); } @@ -257,11 +384,10 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { if (isFlushing) { trackError("UnterminatedQuote", "Unterminated quoted field"); const raw = text.substring(contentStart); - fields[fi++] = hasEscapes - ? raw.replaceAll(escapedQuote, quoteChar) - : raw; - if (numCols === 0) numCols = fi; - else if (fi < numCols) fields.length = fi; + fields.push( + hadEscaped ? raw.replaceAll(escapedQuote, quoteChar) : raw, + ); + if (numCols === 0) numCols = fields.length; enqueue(fields); idx++; } @@ -272,12 +398,10 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { return; } - // Extract field value: single slice + conditional replaceAll - const field = hasEscapes - ? text - .substring(contentStart, closeQ) - .replaceAll(escapedQuote, quoteChar) - : text.substring(contentStart, closeQ); + const slice = text.substring(contentStart, closeQ); + const field = hadEscaped + ? slice.replaceAll(escapedQuote, quoteChar) + : slice; if (field.length > fieldMaxSize) { throw new Error( `CSV field size (${field.length}) exceeds fieldMaxSize (${fieldMaxSize} bytes)`, @@ -285,74 +409,58 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { } pos = closeQ + 1; - // Post-quote dispatch: delimiter, newline, or end-of-input - if (pos >= len) { - fields[fi++] = field; - fieldStart = pos; - break; - } - const nc = text.charCodeAt(pos); - if ( - delimiterCharSingle - ? nc === delimiterCharCode - : text.startsWith(delimiterChar, pos) - ) { - fields[fi++] = field; + // Post-quote dispatch: delimiter, newline, or end-of-input. + // At end-of-input charCodeAt(pos) is NaN, so neither the delimiter + // nor the newline branch matches and the field falls through to the + // "garbage after closing quote" branch, which records the field and + // lets the outer loop terminate — no explicit end guard needed. + if (text.startsWith(delimiterChar, pos)) { + fields.push(field); pos += delimiterCharLength; fieldStart = pos; lastWasDelimiter = true; continue; } - if ( - nc === newlineCharCode && - (newlineCharLength === 1 || - (newlineCharLength === 2 && - pos + 1 < len && - text.charCodeAt(pos + 1) === newlineCharSingle) || - (newlineCharLength > 2 && text.startsWith(newlineChar, pos))) - ) { - fields[fi++] = field; - if (numCols === 0) { - numCols = fi; - rowTpl = Array(numCols).fill(""); - } else if (fi < numCols) fields.length = fi; + if (text.startsWith(newlineChar, pos)) { + fields.push(field); + if (numCols === 0) numCols = fields.length; enqueue(fields); idx++; - fi = 0; - fields = rowTpl ? rowTpl.slice() : []; + fields = []; pos += newlineCharLength; rowStart = pos; fieldStart = pos; lastWasDelimiter = false; continue; } - // Garbage after closing quote - fields[fi++] = field; + // Garbage after closing quote (also the end-of-input case) + fields.push(field); fieldStart = pos; continue; } - // escapeChar !== quoteChar — use indexOf with lookback + // escapeChar !== quoteChar — find the closing quote with indexOf and a + // run-length lookback. A quote is escaped (does not close the field) only + // when the run of escapeChar immediately before it is odd; an even run + // (e.g. "\\" => an escaped escape) leaves a real closing quote. The + // opening quote (which differs from escapeChar here) terminates the + // lookback, so no explicit lower bound is needed. unescapeCustom reverses + // the escaping and is a no-op on a field with no escapeChar, so it is + // applied unconditionally. let closeQ = text.indexOf(quoteChar, pos); - let hasEscape = false; - while ( - closeQ !== -1 && - closeQ > 0 && - text.charCodeAt(closeQ - 1) === escapeCharCode - ) { - hasEscape = true; + // Skip escaped quotes; a "not found" (-1) is treated as not-escaped and + // ends the loop. + while (quoteIsEscaped(text, closeQ, contentStart, escapeCharCode)) { closeQ = text.indexOf(quoteChar, closeQ + 1); } if (closeQ === -1) { // Unterminated quote - const raw = text.substring(contentStart); - const field = hasEscape ? raw.replaceAll(escapedQuote, quoteChar) : raw; + const field = unescapeCustom(text.substring(contentStart), escapeChar); if (isFlushing) { trackError("UnterminatedQuote", "Unterminated quoted field"); - fields[fi++] = field; - if (numCols === 0) numCols = fi; - else if (fi < numCols) fields.length = fi; + fields.push(field); + if (numCols === 0) numCols = fields.length; enqueue(fields); idx++; } @@ -363,13 +471,12 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { return; } - // Extract field value: single slice + conditional replaceAll + // Extract field value: single slice + unescape (no-op without escapes) { - const field = hasEscape - ? text - .substring(contentStart, closeQ) - .replaceAll(escapedQuote, quoteChar) - : text.substring(contentStart, closeQ); + const field = unescapeCustom( + text.substring(contentStart, closeQ), + escapeChar, + ); if (field.length > fieldMaxSize) { throw new Error( `CSV field size (${field.length}) exceeds fieldMaxSize (${fieldMaxSize} bytes)`, @@ -377,41 +484,21 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { } pos = closeQ + 1; - // Post-quote dispatch: delimiter, newline, or end-of-input - if (pos >= len) { - fields[fi++] = field; - fieldStart = pos; - break; - } - const nc = text.charCodeAt(pos); - if ( - delimiterCharSingle - ? nc === delimiterCharCode - : text.startsWith(delimiterChar, pos) - ) { - fields[fi++] = field; + // Post-quote dispatch: delimiter, newline, or end-of-input (see the + // escapeIsQuote branch above — the garbage branch also covers EOI). + if (text.startsWith(delimiterChar, pos)) { + fields.push(field); pos += delimiterCharLength; fieldStart = pos; lastWasDelimiter = true; continue; } - if ( - nc === newlineCharCode && - (newlineCharLength === 1 || - (newlineCharLength === 2 && - pos + 1 < len && - text.charCodeAt(pos + 1) === newlineCharSingle) || - (newlineCharLength > 2 && text.startsWith(newlineChar, pos))) - ) { - fields[fi++] = field; - if (numCols === 0) { - numCols = fi; - rowTpl = Array(numCols).fill(""); - } else if (fi < numCols) fields.length = fi; + if (text.startsWith(newlineChar, pos)) { + fields.push(field); + if (numCols === 0) numCols = fields.length; enqueue(fields); idx++; - fi = 0; - fields = rowTpl ? rowTpl.slice() : []; + fields = []; pos += newlineCharLength; rowStart = pos; fieldStart = pos; @@ -419,146 +506,70 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { continue; } // Garbage after closing quote - fields[fi++] = field; + fields.push(field); fieldStart = pos; continue; } } - // === UNQUOTED FIELDS — indexOf scan === + // === UNQUOTED FIELD — one field per outer iteration === + // The next quote/newline/delimiter is resolved, the field emitted, and the + // outer loop re-entered so the following field-start is re-dispatched + // (handling a quote that opens the next field). lastWasDelimiter = false; { - let nextNl = text.indexOf(newlineChar, pos); - - // Fast path: no quotes — column-aware indexOf loop - // Finds row boundary first, then processes columns within - // bounds. Fewer allocations than split (no intermediate - // line strings). Handles short/long rows via split fallback. - if ( - fi === 0 && - numCols > 0 && - text.indexOf(quoteChar, fieldStart) === -1 - ) { - const lastFi = numCols - 1; - let rowEnd = nextNl; - while (rowEnd !== -1) { - for (fi = 0; fi < lastFi; fi++) { - const d = text.indexOf(delimiterChar, fieldStart); - if (d === -1 || d > rowEnd) { - // Malformed row: split fallback - fields = text.substring(pos, rowEnd).split(delimiterChar); - fi = numCols; // sentinel: skip lastFi assign - break; - } - fields[fi] = text.substring(fieldStart, d); - fieldStart = d + delimiterCharLength; - } - if (fi === lastFi) { - fields[lastFi] = text.substring(fieldStart, rowEnd); - } - enqueue(fields); - idx++; - fi = 0; - pos = rowEnd + newlineCharLength; - rowStart = pos; - fieldStart = pos; - fields = rowTpl.slice(); - rowEnd = text.indexOf(newlineChar, pos); - } - if (pos >= len) { - break; - } - // Partial row without newline: fall through to regular path - nextNl = -1; + if (nextNl !== -1 && nextNl < pos) { + nextNl = text.indexOf(newlineChar, pos); + } + const nextDelim = text.indexOf(delimiterChar, pos); + + if (nextDelim !== -1 && (nextNl === -1 || nextDelim <= nextNl)) { + // Field terminated by a delimiter (which wins a tie with the newline, + // e.g. when the delimiter is a prefix of the newline) → more fields. + fields.push(text.substring(fieldStart, nextDelim)); + pos = nextDelim + delimiterCharLength; + fieldStart = pos; + lastWasDelimiter = true; + continue; } - // First-row detection: use split to establish numCols - if ( - fi === 0 && - numCols === 0 && - nextNl !== -1 && - text.indexOf(quoteChar, fieldStart) === -1 - ) { - const lineFields = text - .substring(fieldStart, nextNl) - .split(delimiterChar); - numCols = lineFields.length; - rowTpl = Array(numCols).fill(""); - enqueue(lineFields); + if (nextNl !== -1) { + // Field terminated by a newline → end of row. + fields.push(text.substring(fieldStart, nextNl)); + if (numCols === 0) numCols = fields.length; + enqueue(fields); idx++; + fields = []; pos = nextNl + newlineCharLength; rowStart = pos; fieldStart = pos; - fields = rowTpl.slice(); - if (pos >= len) { - break; - } - // Re-enter the fast path via continue outer continue; } - - // Regular indexOf path - while (pos < len) { - const nextDelim = text.indexOf(delimiterChar, pos); - - if (nextNl !== -1 && (nextDelim === -1 || nextNl < nextDelim)) { - fields[fi++] = text.substring(fieldStart, nextNl); - if (numCols === 0) { - numCols = fi; - rowTpl = Array(numCols).fill(""); - } else if (fi < numCols) fields.length = fi; - enqueue(fields); - idx++; - fi = 0; - fields = rowTpl ? rowTpl.slice() : []; - pos = nextNl + newlineCharLength; - rowStart = pos; - fieldStart = pos; - lastWasDelimiter = false; - nextNl = text.indexOf(newlineChar, pos); - if (pos >= len) break; - if (text.charCodeAt(pos) === quoteCharCode) continue outer; - continue; - } - - if (nextDelim !== -1) { - fields[fi++] = text.substring(fieldStart, nextDelim); - pos = nextDelim + delimiterCharLength; - fieldStart = pos; - lastWasDelimiter = true; - if (pos >= len) continue outer; - if (text.charCodeAt(pos) === quoteCharCode) continue outer; - continue; - } - - break; - } } break; } - // Cleanup: partial row at end - if (fieldStart < len || lastWasDelimiter || fi > 0) { - if (isFlushing) { - if (fieldStart < len) { - fields[fi++] = text.substring(fieldStart); - } else if (lastWasDelimiter) { - fields[fi++] = ""; - } - if (fi > 0) { - if (numCols === 0) numCols = fi; - else if (fi < numCols) fields.length = fi; - enqueue(fields); - idx++; - } - } else { - ctx.tail = text.substring(rowStart); - ctx.numCols = numCols; - ctx.idx = idx; - ctx.errors = errors; - return; - } + // Cleanup: a partial row may remain at the end of the chunk. + if (!isFlushing) { + // rowStart marks the start of the unconsumed (incomplete) row; for a fully + // consumed input it equals len and yields an empty tail. + ctx.tail = text.substring(rowStart); + ctx.numCols = numCols; + ctx.idx = idx; + ctx.errors = errors; + return; + } + // Flushing: emit any trailing field or the empty field of a dangling delimiter. + if (fieldStart < len) { + fields.push(text.substring(fieldStart)); + } else if (lastWasDelimiter) { + fields.push(""); + } + if (fields.length > 0) { + if (numCols === 0) numCols = fields.length; + enqueue(fields); + idx++; } ctx.tail = ""; ctx.numCols = numCols; @@ -574,15 +585,8 @@ export const csvQuotedParser = (text, options = {}, isFlushing = false) => { const ctx = { delimiterChar, - delimiterCharCode: options.delimiterCharCode ?? delimiterChar.charCodeAt(0), delimiterCharLength: options.delimiterCharLength ?? delimiterChar.length, - delimiterCharSingle: - options.delimiterCharSingle ?? delimiterChar.length === 1, newlineChar, - newlineCharCode: options.newlineCharCode ?? newlineChar.charCodeAt(0), - newlineCharSingle: - options.newlineCharSingle ?? - (newlineChar.length > 1 ? newlineChar.charCodeAt(1) : -1), newlineCharLength: options.newlineCharLength ?? newlineChar.length, quoteChar, quoteCharCode: options.quoteCharCode ?? quoteChar.charCodeAt(0), @@ -590,9 +594,10 @@ export const csvQuotedParser = (text, options = {}, isFlushing = false) => { escapeCharCode: options.escapeCharCode ?? escapeChar.charCodeAt(0), escapeIsQuote: options.escapeIsQuote ?? escapeChar === quoteChar, escapedQuote: options.escapedQuote ?? escapeChar + quoteChar, + fieldMaxSize: options.fieldMaxSize ?? Number.POSITIVE_INFINITY, numCols: options.numCols ?? 0, idx: options.idx ?? 0, - tail: "", + // `tail`/`errors` are always assigned by csvParseInline before being read. errors: null, }; const rows = []; @@ -650,11 +655,11 @@ const csvSteamifyParser = (options = {}) => { escapeChar, fieldMaxSize, } = options; - const useCustomParser = parser != null; parser ??= csvQuotedParser; - let resolved = false; - const ctx = { numCols: 0, idx: 0, tail: "", errors: null }; + // Per-chunk parser context; every field is (re)assigned by resolveOptions and + // the parser result before it is read (numCols/idx default via `?? 0`). + const ctx = {}; let buffer = ""; const errors = {}; @@ -679,13 +684,8 @@ const csvSteamifyParser = (options = {}) => { escapeChar = resolveLazy(escapeChar) ?? quoteChar; ctx.delimiterChar = delimiterChar; - ctx.delimiterCharCode = delimiterChar.charCodeAt(0); ctx.delimiterCharLength = delimiterChar.length; - ctx.delimiterCharSingle = delimiterChar.length === 1; ctx.newlineChar = newlineChar; - ctx.newlineCharCode = newlineChar.charCodeAt(0); - ctx.newlineCharSingle = - newlineChar.length > 1 ? newlineChar.charCodeAt(1) : -1; ctx.newlineCharLength = newlineChar.length; ctx.quoteChar = quoteChar; ctx.quoteCharCode = quoteChar.charCodeAt(0); @@ -693,56 +693,42 @@ const csvSteamifyParser = (options = {}) => { ctx.escapeCharCode = escapeChar.charCodeAt(0); ctx.escapeIsQuote = escapeChar === quoteChar; ctx.escapedQuote = escapeChar + quoteChar; - ctx.fieldMaxSize = fieldMaxSize ?? 16_777_216; - resolved = true; + ctx.fieldMaxSize = fieldMaxSize; }; const streamFn = (chunk, enqueue) => { - if (!resolved) resolveOptions(); - const str = typeof chunk === "string" ? chunk : chunk.toString(); - const text = buffer.length > 0 ? buffer + str : str; - buffer = ""; + // resolveLazy is idempotent on already-resolved values, so re-resolving on + // every chunk is safe and keeps lazy options deferred until upstream runs. + resolveOptions(); + // String#toString returns the string itself, so this also handles string + // chunks; an empty buffer concatenates to just the chunk. + const text = buffer + chunk.toString(); + // `buffer` is reassigned from the parse tail below before it is read again. if (text.length > ctx.fieldMaxSize * 2) { throw new Error( `CSV buffer size (${text.length}) exceeds safety limit, likely unterminated quoted field`, ); } - if (useCustomParser) { - const result = parser(text, ctx, false); - ctx.numCols = result.numCols; - ctx.idx = result.idx; - buffer = result.tail; - if (result.errors) mergeErrors(result.errors); - const rows = result.rows; - for (let i = 0; i < rows.length; i++) enqueue(rows[i]); - } else { - ctx.tail = ""; - ctx.errors = null; - csvParseInline(text, ctx, false, enqueue); - buffer = ctx.tail; - if (ctx.errors !== null) mergeErrors(ctx.errors); - } + const result = parser(text, ctx, false); + ctx.numCols = result.numCols; + ctx.idx = result.idx; + buffer = result.tail; + mergeErrors(result.errors); + const rows = result.rows; + for (let i = 0; i < rows.length; i++) enqueue(rows[i]); }; streamFn.flush = (enqueue) => { - if (!resolved) resolveOptions(); + resolveOptions(); if (buffer.length > 0) { const remaining = buffer; - buffer = ""; - if (useCustomParser) { - const result = parser(remaining, ctx, true); - ctx.numCols = result.numCols; - ctx.idx = result.idx; - if (result.errors) mergeErrors(result.errors); - const rows = result.rows; - for (let i = 0; i < rows.length; i++) enqueue(rows[i]); - } else { - ctx.tail = ""; - ctx.errors = null; - csvParseInline(remaining, ctx, true, enqueue); - if (ctx.errors !== null) mergeErrors(ctx.errors); - } + const result = parser(remaining, ctx, true); + ctx.numCols = result.numCols; + ctx.idx = result.idx; + mergeErrors(result.errors); + const rows = result.rows; + for (let i = 0; i < rows.length; i++) enqueue(rows[i]); } }; @@ -754,42 +740,22 @@ const csvSteamifyParser = (options = {}) => { export const csvParseStream = (options = {}, streamOptions = {}) => { const { - chunkSize = 2_097_152, // 2MB + // chunkSize is accepted for backwards compatibility; the streaming parser + // buffers partial rows itself, so chunks are parsed as they arrive. + chunkSize: _chunkSize, fieldMaxSize = 16_777_216, // 16MB resultKey, ...parserOptions } = options; parserOptions.fieldMaxSize = fieldMaxSize; - streamOptions.highWaterMark ??= 16384; const streamParse = csvSteamifyParser(parserOptions); - let inputChunks = []; - let inputLen = 0; - let ready = false; - const transform = (chunk, enqueue) => { - if (!ready) { - inputChunks.push(chunk); - inputLen += chunk.length; - if (inputLen < chunkSize) return; - ready = true; - const text = - inputChunks.length === 1 ? inputChunks[0] : inputChunks.join(""); - inputChunks = null; - streamParse(text, enqueue); - } else { - streamParse(chunk, enqueue); - } + streamParse(chunk, enqueue); }; const flush = (enqueue) => { - if (!ready && inputLen > 0) { - const text = - inputChunks.length === 1 ? inputChunks[0] : inputChunks.join(""); - inputChunks = null; - streamParse(text, enqueue); - } streamParse.flush(enqueue); }; @@ -850,9 +816,8 @@ export const csvRemoveEmptyRowsStream = (options = {}, streamOptions = {}) => { let idx = -1; const isEmpty = (chunk) => { - const l = chunk.length; - if (l === 0) return true; - for (let i = 0; i < l; i++) { + // A zero-length row falls through the loop and returns true as well. + for (let i = 0; i < chunk.length; i++) { if (chunk[i] !== "") return false; } return true; @@ -891,33 +856,22 @@ const autoCoerce = (val) => { const len = val.length; if (len === 0) return null; const c0 = val.charCodeAt(0); - // Fast boolean check: avoid toLowerCase() for non-boolean strings - if (len === 4 && (c0 === 116 || c0 === 84)) { - // 't' or 'T' - const lower = val.toLowerCase(); - if (lower === "true") return true; - } else if (len === 5 && (c0 === 102 || c0 === 70)) { - // 'f' or 'F' - const lower = val.toLowerCase(); - if (lower === "false") return false; + const lower = val.toLowerCase(); + if (lower === "true") return true; + if (lower === "false") return false; + // Number then ISO date — both regexes are anchored and only match + // digit/minus-prefixed strings, so non-numeric input falls through. + if (numberRe.test(val)) return Number(val); + if (iso8601Re.test(val)) { + const d = new Date(val); + if (!Number.isNaN(d.getTime())) return d; } - // Number: starts with digit or minus sign - if ( - (c0 >= 48 && c0 <= 57) || // '0'-'9' - c0 === 45 // '-' - ) { - if (numberRe.test(val)) return Number(val); - if (iso8601Re.test(val)) return new Date(val); - return val; - } - // ISO date: starts with digit (already handled above) - // JSON: starts with '{' or '[' + // JSON: only attempt for '{' or '[' so values like "null" are not parsed. + // On a parse error fall through to the final `return val`. if (c0 === 123 || c0 === 91) { try { return JSON.parse(val); - } catch { - return val; - } + } catch {} } return val; }; @@ -939,12 +893,13 @@ const coerceToType = (val, type) => { const d = new Date(val); return Number.isNaN(d.getTime()) ? val : d; } - case "json": + case "json": { try { return JSON.parse(val); - } catch { - return val; - } + } catch {} + // On a JSON parse error, keep the original string. + return val; + } default: return val; } @@ -998,58 +953,34 @@ export const csvFormatStream = (options = {}, streamOptions = {}) => { const quoteChar = options.quoteChar ?? defaultQuoteChar; const escapeChar = options.escapeChar ?? quoteChar; - // Pre-compute char codes and flags once at stream creation - const delimiterCode = delimiterChar.charCodeAt(0); - const delimiterSingle = delimiterChar.length === 1; - const quoteCode = quoteChar.charCodeAt(0); + // Pre-compute escaping flags/strings once at stream creation const escapeIsQuote = escapeChar === quoteChar; const escapedQuote = escapeChar + quoteChar; const escapedEscape = escapeChar + escapeChar; - // Single-pass charCode scan for single-char delimiter (common case), - // includes fallback for multi-char - const scanNeedsQuote = delimiterSingle - ? (value) => { - const len = value.length; - const first = value.charCodeAt(0); - // = (61) + (43) - (45) @ (64) space (32) BOM (FEFF) - if ( - first === 61 || - first === 43 || - first === 45 || - first === 64 || - first === 32 || - first === 0xfeff - ) - return true; - if (value.charCodeAt(len - 1) === 32) return true; - for (let i = 0; i < len; i++) { - const c = value.charCodeAt(i); - if (c === delimiterCode || c === quoteCode || c === 13 || c === 10) - return true; - } - return false; - } - : (value) => { - const len = value.length; - const first = value.charCodeAt(0); - if ( - first === 61 || - first === 43 || - first === 45 || - first === 64 || - first === 32 || - first === 0xfeff - ) - return true; - if (value.charCodeAt(len - 1) === 32) return true; - if (value.includes(delimiterChar)) return true; - for (let i = 0; i < len; i++) { - const c = value.charCodeAt(i); - if (c === quoteCode || c === 13 || c === 10) return true; - } - return false; - }; + // A field must be quoted when it starts with a formula/whitespace/BOM + // trigger, ends with a space, or contains the delimiter, the quote char, + // or a CR/LF. The leading-char trigger is checked by code; the rest are + // substring containment checks (delimiter may be multi-char). + const startsWithTrigger = (value) => { + // = (61) + (43) - (45) @ (64) space (32) BOM (FEFF) + const first = value.charCodeAt(0); + return ( + first === 61 || + first === 43 || + first === 45 || + first === 64 || + first === 32 || + first === 0xfeff + ); + }; + const scanNeedsQuote = (value) => + startsWithTrigger(value) || + value.charCodeAt(value.length - 1) === 32 || + value.includes(delimiterChar) || + value.includes(quoteChar) || + value.includes("\r") || + value.includes("\n"); // Skip replaceAll when value has no chars that need escaping (common: // field quoted because of delimiter/newline, but contains no quote chars) @@ -1067,39 +998,22 @@ export const csvFormatStream = (options = {}, streamOptions = {}) => { : quoteChar + v + quoteChar; }; - // Fast path: all fields are strings (or null/undefined) and none need - // quoting → use Array.join (single native allocation + memcpy) instead - // of per-field ConsString concatenation. join converts null/undefined - // to "" which matches empty-field CSV semantics. - const isSimpleRow = (chunk) => { + // Build one row string: coerce each field, quote/escape where required, + // then join with the delimiter (one flat string per row). + const formatRow = (chunk) => { + const parts = []; for (let i = 0; i < chunk.length; i++) { - const val = chunk[i]; - if (val == null) continue; - if (typeof val !== "string") return false; - if (val.length > 0 && scanNeedsQuote(val)) return false; - } - return true; - }; - - // Slow path: pre-allocated parts array + join (produces flat string - // directly, avoids ~2n ConsString nodes from per-field concatenation) - const formatRowSlow = (chunk) => { - const len = chunk.length; - const parts = new Array(len); - for (let i = 0; i < len; i++) { - let val = chunk[i]; - if (val == null) { - parts[i] = ""; + const raw = chunk[i]; + if (raw == null) { + // null/undefined → empty field + parts.push(""); continue; } - if (typeof val !== "string") { - val = val instanceof Date ? val.toISOString() : String(val); - } - if (val.length === 0) { - parts[i] = ""; - continue; - } - parts[i] = scanNeedsQuote(val) ? wrapQuote(val) : val; + // Strings pass through String() unchanged; Dates use ISO 8601. An empty + // string is never a quoting trigger, so scanNeedsQuote handles it + // directly without a special case. + const val = raw instanceof Date ? raw.toISOString() : String(raw); + parts.push(scanNeedsQuote(val) ? wrapQuote(val) : val); } return parts.join(delimiterChar); }; @@ -1110,9 +1024,7 @@ export const csvFormatStream = (options = {}, streamOptions = {}) => { const batch = []; const transform = (chunk, enqueue) => { - batch.push( - isSimpleRow(chunk) ? chunk.join(delimiterChar) : formatRowSlow(chunk), - ); + batch.push(formatRow(chunk)); if (batch.length >= 64) { enqueue(batch.join(newlineChar) + newlineChar); batch.length = 0; @@ -1129,7 +1041,27 @@ export const csvFormatStream = (options = {}, streamOptions = {}) => { return createTransformStream(transform, flush, streamOptions); }; -export const csvArrayToObject = ({ headers }, streamOptions) => - objectFromEntriesStream({ keys: headers }, streamOptions); +export const csvArrayToObject = ({ headers }, streamOptions = {}) => { + let resolvedKeys; + const transform = (chunk, enqueue) => { + resolvedKeys ??= resolveLazy(headers); + const value = {}; + for (let i = 0; i < resolvedKeys.length; i++) { + // defineProperty is used for every column so reserved keys such as + // "__proto__" become own enumerable data properties instead of mutating + // the object's prototype (which a plain `value[key] = ...` would do, + // silently dropping the column). For ordinary keys this is equivalent + // to a normal assignment. + Object.defineProperty(value, resolvedKeys[i], { + value: chunk[i], + writable: true, + enumerable: true, + configurable: true, + }); + } + enqueue(value); + }; + return createTransformStream(transform, streamOptions); +}; export const csvObjectToArray = ({ headers }, streamOptions) => objectToEntriesStream({ keys: headers }, streamOptions); diff --git a/packages/csv/index.test.js b/packages/csv/index.test.js index e11aaaa..2eddaef 100644 --- a/packages/csv/index.test.js +++ b/packages/csv/index.test.js @@ -1773,3 +1773,3379 @@ test(`${variant}: csvCoerceValuesStream should handle uppercase boolean`, async deepStrictEqual(output, [{ val: true, val2: false }]); }); + +// *** FINDING: fast-path-too-many-columns-merged *** // +test(`${variant}: csvParseStream should not merge extra columns in fast unquoted path`, async (_t) => { + // A row with MORE fields than the established numCols must keep all + // fields separate (not collapse the surplus into the last field). + const streams = [ + createReadableStream("a,b,c\r\n1,2,3,4,5\r\n"), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + ["a", "b", "c"], + ["1", "2", "3", "4", "5"], + ]); +}); + +test(`${variant}: csvParseStream fast and slow paths agree on over-long rows`, async (_t) => { + // Identical row data must parse identically whether or not a quote char + // appears elsewhere in the buffer (quote presence picks fast vs slow path). + const fastStreams = [ + createReadableStream("a,b,c\r\n1,2,3,4,5\r\n"), + csvParseStream(), + ]; + const slowStreams = [ + createReadableStream('a,b,c\r\n1,2,3,4,5\r\n"x",y,z\r\n'), + csvParseStream(), + ]; + const fast = await streamToArray(pipejoin(fastStreams)); + const slow = await streamToArray(pipejoin(slowStreams)); + + deepStrictEqual(fast[1], ["1", "2", "3", "4", "5"]); + deepStrictEqual(slow[1], ["1", "2", "3", "4", "5"]); +}); + +test(`${variant}: csvParseStream over-long rows are detectable as malformed`, async (_t) => { + const filter = csvRemoveMalformedRowsStream(); + const streams = [ + createReadableStream("a,b,c\r\n1,2,3,4,5\r\n6,7,8\r\n"), + csvParseStream(), + filter, + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + ["a", "b", "c"], + ["6", "7", "8"], + ]); + deepStrictEqual(filter.result().value.MalformedRow.idx, [1]); +}); + +// *** FINDING: detect-quote-scans-whole-buffer *** // +test(`${variant}: csvDetectDelimitersStream should not detect apostrophe in data as quote char`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [ + createReadableStream("quote,author\n'twas the night,Moore\nhello,World\n"), + detect, + ]; + await pipeline(streams); + + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvParseStream should parse data with apostrophes after auto-detect`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const parse = csvParseStream({ + delimiterChar: () => detect.result().value.delimiterChar, + newlineChar: () => detect.result().value.newlineChar, + quoteChar: () => detect.result().value.quoteChar, + }); + const streams = [ + createReadableStream("quote,author\n'twas the night,Moore\nhello,World\n"), + detect, + parse, + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + ["quote", "author"], + ["'twas the night", "Moore"], + ["hello", "World"], + ]); +}); + +// *** FINDING: custom-escape-not-inverse-of-format *** // +test(`${variant}: csvFormatStream/csvParseStream round-trip preserves escape char with custom escapeChar`, async (_t) => { + const value = 'a"b\\c'; + const formatStreams = [ + createReadableStream([[value, "next"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + const formatted = await streamToString(pipejoin(formatStreams)); + + const parseStreams = [ + createReadableStream(formatted), + csvParseStream({ escapeChar: "\\" }), + ]; + const parsed = await streamToArray(pipejoin(parseStreams)); + + deepStrictEqual(parsed, [[value, "next"]]); +}); + +// *** FINDING: custom-escape-lookback-parity *** // +test(`${variant}: csvParseStream should treat even run of escape chars as unescaped closing quote`, async (_t) => { + // "a\\" => field value is a\ (escaped backslash), then the closing quote + // is real, so the delimiter and following field are not swallowed. + const streams = [ + createReadableStream('"a\\\\",b\r\nc,d\r\n'), + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + ["a\\", "b"], + ["c", "d"], + ]); +}); + +// *** FINDING: detect-header-naive-newline-split *** // +test(`${variant}: csvDetectHeaderStream should respect quoted newline in header field`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\n" }); + const streams = [ + createReadableStream('"col\nwith newline",col2\nval1,val2\n'), + headers, + ]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + + deepStrictEqual(headers.result().value.header, ["col\nwith newline", "col2"]); + strictEqual(output, "val1,val2\n"); +}); + +test(`${variant}: csvDetectHeaderStream should respect custom-escape quoted newline in header`, async (_t) => { + const headers = csvDetectHeaderStream({ + newlineChar: "\n", + escapeChar: "\\", + }); + const streams = [ + createReadableStream('"col\\"x\nnext",col2\nval1,val2\n'), + headers, + ]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + + deepStrictEqual(headers.result().value.header, ['col"x\nnext', "col2"]); + strictEqual(output, "val1,val2\n"); +}); + +// *** FINDING: autocoerce-invalid-date *** // +test(`${variant}: csvCoerceValuesStream should keep invalid date string as string`, async (_t) => { + const streams = [ + createReadableStream([{ d: "2024-13-99" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ d: "2024-13-99" }]); +}); + +// *** FINDING: csvArrayToObject-proto-header-drops-column *** // +test(`${variant}: csvArrayToObject should keep a __proto__ header as an own data property`, async (_t) => { + const streams = [ + createReadableStream([["polluted", "ok"]]), + csvArrayToObject({ headers: ["__proto__", "safe"] }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + strictEqual(output.length, 1); + const row = output[0]; + // The __proto__ column must be stored as an own data property, not lost. + ok(Object.hasOwn(row, "__proto__")); + // biome-ignore lint/suspicious/noProto: intentionally reading the own data property literally named "__proto__" to assert prototype-pollution safety. + strictEqual(row.__proto__, "polluted"); + strictEqual(row.safe, "ok"); +}); + +// =================================================================== +// Mutation-killing tests (added to raise mutation score) +// =================================================================== + +// --- csvFormatStream: scanNeedsQuote first-char triggers --- +test(`${variant}: csvFormatStream should quote field with leading space`, async (_t) => { + const streams = [createReadableStream([[" lead", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '" lead",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should quote field with trailing space`, async (_t) => { + const streams = [createReadableStream([["trail ", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"trail ",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should quote field with leading BOM`, async (_t) => { + const streams = [createReadableStream([["bom", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"bom",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should not quote plain interior space`, async (_t) => { + const streams = [createReadableStream([["a b c", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "a b c,ok\r\n"); +}); + +test(`${variant}: csvFormatStream should quote field containing carriage return`, async (_t) => { + const streams = [createReadableStream([["a\rb", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\rb",ok\r\n'); +}); + +// --- csvFormatStream: multi-char delimiter scan path --- +test(`${variant}: csvFormatStream should quote value containing multi-char delimiter`, async (_t) => { + const streams = [ + createReadableStream([["a::b", "plain"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a::b"::plain\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter should quote on quote char`, async (_t) => { + const streams = [ + createReadableStream([['has"quote', "plain"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"has""quote"::plain\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter should not quote plain value`, async (_t) => { + const streams = [ + createReadableStream([["plain", "value"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "plain::value\r\n"); +}); + +// --- csvFormatStream: wrapQuote with custom escape --- +test(`${variant}: csvFormatStream custom escape should escape escape char and quote char`, async (_t) => { + // escapeChar=\, value contains both \ and "; backslash doubled, quote -> \" + const streams = [ + createReadableStream([['a"b\\c', "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\\"b\\\\c",ok\r\n'); +}); + +test(`${variant}: csvFormatStream custom escape should escape only escape char when no quote`, async (_t) => { + const streams = [ + createReadableStream([["a\\b,c", "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + const output = await streamToString(pipejoin(streams)); + // contains delimiter so quoted; backslash doubled; no quote char present + strictEqual(output, '"a\\\\b,c",ok\r\n'); +}); + +// --- csvFormatStream: Date and number/non-string formatting (formatRowSlow) --- +test(`${variant}: csvFormatStream should format Date as ISO string`, async (_t) => { + const d = new Date("2024-01-15T10:30:00.000Z"); + const streams = [createReadableStream([[d, "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "2024-01-15T10:30:00.000Z,ok\r\n"); +}); + +test(`${variant}: csvFormatStream should format number via String`, async (_t) => { + const streams = [createReadableStream([[42, 3.14]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "42,3.14\r\n"); +}); + +test(`${variant}: csvFormatStream should format null and undefined as empty`, async (_t) => { + const streams = [ + createReadableStream([[null, undefined, "x"]]), + csvFormatStream(), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, ",,x\r\n"); +}); + +test(`${variant}: csvFormatStream should format boolean via String in slow path`, async (_t) => { + // boolean true forces slow path (non-string); String(true) === "true" + const streams = [createReadableStream([[true, false]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "true,false\r\n"); +}); + +test(`${variant}: csvFormatStream should quote number-derived string needing quote`, async (_t) => { + // A negative number stringifies to "-42" which starts with '-', a quote trigger + const streams = [createReadableStream([[-42, "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"-42",ok\r\n'); +}); + +// --- csvFormatStream: batch boundary (>= 64 rows flushed mid-stream) --- +test(`${variant}: csvFormatStream should emit batch at 64 rows then flush remainder`, async (_t) => { + const rows = []; + for (let i = 0; i < 70; i++) rows.push([String(i), "x"]); + const streams = [createReadableStream(rows), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + const lines = output.split("\r\n").filter((l) => l.length > 0); + strictEqual(lines.length, 70); + strictEqual(lines[0], "0,x"); + strictEqual(lines[69], "69,x"); +}); + +// --- csvFormatStream: custom newlineChar separator --- +test(`${variant}: csvFormatStream should use custom newlineChar`, async (_t) => { + const streams = [ + createReadableStream([ + ["1", "2"], + ["3", "4"], + ]), + csvFormatStream({ newlineChar: "\n" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "1,2\n3,4\n"); +}); + +// --- csvFormatStream: custom quoteChar --- +test(`${variant}: csvFormatStream should use custom quoteChar`, async (_t) => { + const streams = [ + createReadableStream([["a,b", "ok"]]), + csvFormatStream({ quoteChar: "'" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "'a,b',ok\r\n"); +}); + +// --- csvFormatStream multi-char delimiter: trailing space / CR / LF triggers --- +test(`${variant}: csvFormatStream multi-char delimiter should quote trailing space`, async (_t) => { + const streams = [ + createReadableStream([["trail ", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"trail "::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter should quote on newline`, async (_t) => { + const streams = [ + createReadableStream([["a\nb", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\nb"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter should quote on carriage return`, async (_t) => { + const streams = [ + createReadableStream([["a\rb", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\rb"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter should quote on linefeed only`, async (_t) => { + const streams = [createReadableStream([["a\nb", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\nb",ok\r\n'); +}); + +// --- csvCoerceValuesStream: number regex exponent shape --- +test(`${variant}: csvCoerceValuesStream should coerce exponent with explicit sign and multi-digit exponent`, async (_t) => { + const streams = [ + createReadableStream([{ a: "1e+10", b: "2e-05", c: "1.5E3" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1e10, b: 2e-5, c: 1500 }]); +}); + +test(`${variant}: csvCoerceValuesStream should keep malformed exponent as string`, async (_t) => { + const streams = [ + createReadableStream([{ a: "1e", b: "1e+", c: "1.2.3" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "1e", b: "1e+", c: "1.2.3" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce string with trailing non-number text`, async (_t) => { + const streams = [ + createReadableStream([{ a: "12abc", d: "2024-01-15extra" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "12abc", d: "2024-01-15extra" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce date missing leading anchor`, async (_t) => { + const streams = [ + createReadableStream([{ d: "x2024-01-15" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ d: "x2024-01-15" }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce date with time and timezone offset`, async (_t) => { + const streams = [ + createReadableStream([{ d: "2024-01-15T10:30:00+05:30" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ d: new Date("2024-01-15T10:30:00+05:30") }]); +}); + +// --- csvCoerceValuesStream: boolean first-char/length gating in autoCoerce --- +test(`${variant}: csvCoerceValuesStream should not treat 4-char non-true string as boolean`, async (_t) => { + const streams = [ + createReadableStream([{ a: "trUE", b: "tree", c: "True" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: true, b: "tree", c: true }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce non-t/f boolean-length words`, async (_t) => { + const streams = [ + createReadableStream([{ a: "yes", b: "nope" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "yes", b: "nope" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce truthy when length is wrong`, async (_t) => { + const streams = [ + createReadableStream([{ a: "truer", b: "falsey" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "truer", b: "falsey" }]); +}); + +test(`${variant}: csvCoerceValuesStream should return null for empty string auto-coerce`, async (_t) => { + const streams = [createReadableStream([{ a: "" }]), csvCoerceValuesStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: null }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce single-digit zero and nine`, async (_t) => { + const streams = [ + createReadableStream([{ a: "0", b: "9" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 0, b: 9 }]); +}); + +test(`${variant}: csvCoerceValuesStream should keep plain non-numeric non-bool string`, async (_t) => { + const streams = [ + createReadableStream([{ a: "hello", b: "world" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "hello", b: "world" }]); +}); + +// --- coerceToType boolean: string vs non-string --- +test(`${variant}: csvCoerceValuesStream explicit boolean should lowercase compare string`, async (_t) => { + const streams = [ + createReadableStream([{ a: "TRUE", b: "no", c: "true" }]), + csvCoerceValuesStream({ + columns: { a: "boolean", b: "boolean", c: "boolean" }, + }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: true, b: false, c: true }]); +}); + +test(`${variant}: csvCoerceValuesStream explicit boolean should use Boolean for non-string`, async (_t) => { + const streams = [ + createReadableStream([{ a: 1, b: 0 }]), + csvCoerceValuesStream({ columns: { a: "boolean", b: "boolean" } }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: true, b: false }]); +}); + +// --- coerceToType json with array --- +test(`${variant}: csvCoerceValuesStream explicit json should parse array`, async (_t) => { + const streams = [ + createReadableStream([{ a: "[1,2,3]" }]), + csvCoerceValuesStream({ columns: { a: "json" } }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: [1, 2, 3] }]); +}); + +// --- coerceToType default (unknown type) passthrough --- +test(`${variant}: csvCoerceValuesStream unknown column type should pass value unchanged`, async (_t) => { + const streams = [ + createReadableStream([{ a: "raw" }]), + csvCoerceValuesStream({ columns: { a: "string" } }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "raw" }]); +}); + +// --- coerceToType number coerces hex-like and keeps decimals --- +test(`${variant}: csvCoerceValuesStream explicit number coerces decimal and hex`, async (_t) => { + const streams = [ + createReadableStream([{ a: "3.14", b: "0x1F" }]), + csvCoerceValuesStream({ columns: { a: "number", b: "number" } }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 3.14, b: 31 }]); +}); + +// --- csvDetectDelimitersStream: quote bracketing precision --- +// A quote candidate must OPEN at a field start AND CLOSE at a field end to be +// recognised. The following exercise each isFieldStart / isFieldEnd branch. + +test(`${variant}: csvDetectDelimitersStream should detect quote opening at text start`, async (_t) => { + // "'a'" opens at i===0 and closes before a delimiter + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("'a',b\n1,2\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream should detect quote after delimiter and closing at line end`, async (_t) => { + // second field 'b' opens after a comma and closes before the newline (LF) + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a,'b'\n1,2\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream should detect quote closing before CR`, async (_t) => { + // quoted field closes right before \r in a CRLF newline + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("'a',b\r\n1,2\r\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, "'"); + strictEqual(detect.result().value.newlineChar, "\r\n"); +}); + +test(`${variant}: csvDetectDelimitersStream should detect quote opening after newline`, async (_t) => { + // The only properly-bracketed quote is on row 2, opening right after the LF + const detect = csvDetectDelimitersStream({ chunkSize: 0 }); + const streams = [createReadableStream("a,b\n'c',d\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream should NOT detect quote that never closes at a field end`, async (_t) => { + // 'x is a field-start apostrophe with no closing quote at any field end. + // A later lone apostrophe sits mid-field (after a letter, not a boundary), + // so it neither opens nor closes a field -> default double-quote. + const detect = csvDetectDelimitersStream(); + const streams = [ + createReadableStream("name,note\n'x,it's fine\nhello,world\n"), + detect, + ]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream should set escapeChar equal to detected quoteChar`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("'a','b'\n1,2\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.escapeChar, "'"); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream should pick first delimiter by precedence (tab over comma)`, async (_t) => { + // Header contains BOTH a tab and a comma; detection order is [tab,pipe,semi,comma] + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a\tb,c\n1\t2,3\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.delimiterChar, "\t"); +}); + +test(`${variant}: csvDetectDelimitersStream should pick pipe over semicolon and comma`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a|b;c,d\n1|2;3,4\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.delimiterChar, "|"); +}); + +test(`${variant}: csvDetectDelimitersStream should pick semicolon over comma`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a;b,c\n1;2,3\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.delimiterChar, ";"); +}); + +test(`${variant}: csvDetectDelimitersStream should default to double-quote when no quote present`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a,b,c\n1,2,3\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream should set newlineChar from header match`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a,b\r1,2\r"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.newlineChar, "\r"); +}); + +// --- csvQuotedParser direct: numCols, escapes, tail, multi-char newline --- +test(`${variant}: csvQuotedParser establishes numCols from first row`, (_t) => { + const result = csvQuotedParser("a,b,c\r\n1,2,3\r\n"); + strictEqual(result.numCols, 3); + deepStrictEqual(result.rows, [ + ["a", "b", "c"], + ["1", "2", "3"], + ]); +}); + +test(`${variant}: csvQuotedParser returns tail for incomplete trailing row`, (_t) => { + const result = csvQuotedParser("a,b\r\n1,2\r\n3,4"); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["1", "2"], + ]); + strictEqual(result.tail, "3,4"); +}); + +test(`${variant}: csvQuotedParser preserves quoted field with escaped quotes (no escapes flag)`, (_t) => { + const result = csvQuotedParser('"plain",x\r\n'); + deepStrictEqual(result.rows, [["plain", "x"]]); +}); + +test(`${variant}: csvQuotedParser keeps doubled-quote escapes`, (_t) => { + const result = csvQuotedParser('"a""b","c"\r\n'); + deepStrictEqual(result.rows, [['a"b', "c"]]); +}); + +test(`${variant}: csvQuotedParser handles short quoted row truncating to fi`, (_t) => { + // numCols established as 3, second row has a single quoted field -> length 1 + const result = csvQuotedParser('a,b,c\r\n"d"\r\n'); + deepStrictEqual(result.rows[0], ["a", "b", "c"]); + strictEqual(result.rows[1].length, 1); + deepStrictEqual(result.rows[1], ["d"]); +}); + +test(`${variant}: csvQuotedParser handles multi-char newline length>2`, (_t) => { + const result = csvQuotedParser("a,b||~c,d||~", { newlineChar: "||~" }, true); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvQuotedParser CRLF requires both chars (lone CR does not split)`, (_t) => { + // With CRLF newline, a lone \r mid-field must NOT terminate the row. + const result = csvQuotedParser('"a\rb",c\r\n', { newlineChar: "\r\n" }, true); + deepStrictEqual(result.rows, [["a\rb", "c"]]); +}); + +test(`${variant}: csvQuotedParser tracks unterminated quote error on flush`, (_t) => { + const result = csvQuotedParser('"abc', {}, true); + deepStrictEqual(result.rows, [["abc"]]); + ok(result.errors.UnterminatedQuote); + deepStrictEqual(result.errors.UnterminatedQuote.idx, [0]); +}); + +test(`${variant}: csvQuotedParser unterminated quote not flushing returns tail`, (_t) => { + const result = csvQuotedParser('a,b\r\n"abc', {}, false); + deepStrictEqual(result.rows, [["a", "b"]]); + strictEqual(result.tail, '"abc'); +}); + +test(`${variant}: csvQuotedParser custom escape unterminated keeps unescaped value on flush`, (_t) => { + const result = csvQuotedParser('"a\\"b', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [['a"b']]); + ok(result.errors.UnterminatedQuote); +}); + +test(`${variant}: csvQuotedParser custom escape even run is real closing quote`, (_t) => { + const result = csvQuotedParser('"a\\\\",b\r\n', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [["a\\", "b"]]); +}); + +test(`${variant}: csvQuotedParser custom escape odd run escapes the quote`, (_t) => { + const result = csvQuotedParser('"a\\"b",c\r\n', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [['a"b', "c"]]); +}); + +// --- field size limit --- +test(`${variant}: csvParseStream should throw when quoted field exceeds fieldMaxSize`, async (_t) => { + // field length 11 (> 10) but buffer (17) stays under the 2x safety limit (20) + const val = "x".repeat(11); + const streams = [ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ fieldMaxSize: 10 }), + ]; + let threw = false; + try { + await pipeline(streams); + } catch (e) { + threw = true; + ok(/CSV field size/.test(e.message)); + } + ok(threw); +}); + +test(`${variant}: csvParseStream should accept quoted field at exactly fieldMaxSize`, async (_t) => { + const val = "x".repeat(10); + const streams = [ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ fieldMaxSize: 10 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [[val, "y"]]); +}); + +test(`${variant}: csvParseStream should throw when custom-escape quoted field exceeds fieldMaxSize`, async (_t) => { + const val = "x".repeat(11); + const streams = [ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ escapeChar: "\\", fieldMaxSize: 10 }), + ]; + let threw = false; + try { + await pipeline(streams); + } catch (e) { + threw = true; + ok(/CSV field size/.test(e.message)); + } + ok(threw); +}); + +test(`${variant}: csvParseStream should throw on buffer exceeding safety limit`, async (_t) => { + // An unterminated quote that grows the buffer beyond fieldMaxSize*2 throws. + const big = `"${"x".repeat(60)}`; + const streams = [ + createReadableStream(big), + csvParseStream({ fieldMaxSize: 10, chunkSize: 1 }), + ]; + let threw = false; + try { + await pipeline(streams); + } catch (e) { + threw = true; + ok(/safety limit/.test(e.message)); + } + ok(threw); +}); + +// --- fast unquoted path: too-few columns (malformed) and surplus --- +test(`${variant}: csvParseStream fast path keeps too-few-column row as-is`, async (_t) => { + const streams = [ + createReadableStream("a,b,c\r\nd,e\r\nf,g,h\r\n"), + csvParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b", "c"], + ["d", "e"], + ["f", "g", "h"], + ]); +}); + +test(`${variant}: csvParseStream fast path keeps multiple full rows`, async (_t) => { + const streams = [ + createReadableStream("a,b\r\n1,2\r\n3,4\r\n5,6\r\n"), + csvParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ["5", "6"], + ]); +}); + +test(`${variant}: csvParseStream fast path partial last row without newline`, async (_t) => { + const streams = [createReadableStream("a,b\r\n1,2\r\n3,4"), csvParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ]); +}); + +// --- multi-char newline (length 2 custom, not CRLF) --- +test(`${variant}: csvParseStream supports 2-char custom newline`, async (_t) => { + const streams = [ + createReadableStream("a,b~|1,2~|"), + csvParseStream({ newlineChar: "~|" }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ]); +}); + +test(`${variant}: csvParseStream 2-char newline does not split on partial match`, async (_t) => { + // A lone '~' that is not followed by '|' must stay inside the field. + const streams = [ + createReadableStream('"a~b",c~|d,e~|'), + csvParseStream({ newlineChar: "~|" }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a~b", "c"], + ["d", "e"], + ]); +}); + +// --- unquoted parser numCols + multi-row --- +test(`${variant}: csvUnquotedParser sets numCols and parses many rows`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2\r\n3,4\r\n"); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ]); +}); + +test(`${variant}: csvUnquotedParser custom delimiter and newline`, (_t) => { + const result = csvUnquotedParser("a;b\nc;d\n", { + delimiterChar: ";", + newlineChar: "\n", + }); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- csvDetectHeaderStream / findRowEnd quote & escape aware row scan --- +test(`${variant}: csvDetectHeaderStream multi-char delimiter splits header`, async (_t) => { + const headers = csvDetectHeaderStream({ + newlineChar: "\n", + delimiterChar: "::", + }); + const streams = [createReadableStream("a::b::c\n1::2::3\n"), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["a", "b", "c"]); + strictEqual(output, "1::2::3\n"); +}); + +test(`${variant}: csvDetectHeaderStream respects delimiter inside quoted header field`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\n" }); + const streams = [createReadableStream('"a,b",c\n1,2\n'), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["a,b", "c"]); + strictEqual(output, "1,2\n"); +}); + +test(`${variant}: csvDetectHeaderStream custom-escape even run closes header quote`, async (_t) => { + const headers = csvDetectHeaderStream({ + newlineChar: "\n", + escapeChar: "\\", + }); + const streams = [createReadableStream('"a\\\\",b\nv1,v2\n'), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["a\\", "b"]); + strictEqual(output, "v1,v2\n"); +}); + +test(`${variant}: csvDetectHeaderStream passes through data after a quoted header newline`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\n" }); + const streams = [ + createReadableStream('"h1\nstill h1",h2\nr1a,r1b\nr2a,r2b\n'), + headers, + ]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["h1\nstill h1", "h2"]); + strictEqual(output, "r1a,r1b\nr2a,r2b\n"); +}); + +test(`${variant}: csvDetectHeaderStream CRLF header newline length`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\r\n" }); + const streams = [createReadableStream("a,b,c\r\n1,2,3\r\n"), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["a", "b", "c"]); + strictEqual(output, "1,2,3\r\n"); +}); + +test(`${variant}: csvDetectHeaderStream emits remaining rest only when non-empty`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\n" }); + const streams = [createReadableStream("only,header\n"), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["only", "header"]); + strictEqual(output, ""); +}); + +// --- csvRemoveMalformedRowsStream result key + default + idx --- +test(`${variant}: csvRemoveMalformedRowsStream default resultKey and message`, async (_t) => { + const filter = csvRemoveMalformedRowsStream(); + const streams = [createReadableStream([["a", "b"], ["c"]]), filter]; + const result = await pipeline(streams); + strictEqual(filter.result().key, "csvRemoveMalformedRows"); + deepStrictEqual(result.csvRemoveMalformedRows.MalformedRow.idx, [1]); + strictEqual( + filter.result().value.MalformedRow.message, + "Row has incorrect number of fields", + ); + strictEqual(filter.result().value.MalformedRow.id, "MalformedRow"); +}); + +test(`${variant}: csvRemoveMalformedRowsStream first row sets count, later mismatch dropped`, async (_t) => { + const filter = csvRemoveMalformedRowsStream(); + const streams = [ + createReadableStream([["a", "b", "c"], ["1", "2", "3"], ["x"]]), + filter, + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b", "c"], + ["1", "2", "3"], + ]); + deepStrictEqual(filter.result().value.MalformedRow.idx, [2]); +}); + +// --- csvRemoveEmptyRowsStream default resultKey + message --- +test(`${variant}: csvRemoveEmptyRowsStream default resultKey and message`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const streams = [ + createReadableStream([ + ["", ""], + ["1", "2"], + ]), + filter, + ]; + const result = await pipeline(streams); + strictEqual(filter.result().key, "csvRemoveEmptyRows"); + strictEqual(result.csvRemoveEmptyRows.EmptyRow.message, "Row is empty"); + strictEqual(result.csvRemoveEmptyRows.EmptyRow.id, "EmptyRow"); +}); + +test(`${variant}: csvRemoveEmptyRowsStream keeps row with one non-empty field`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const streams = [ + createReadableStream([ + ["", "x"], + ["", ""], + ]), + filter, + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [["", "x"]]); + deepStrictEqual(filter.result().value.EmptyRow.idx, [1]); +}); + +test(`${variant}: csvRemoveEmptyRowsStream keeps row whose only non-empty field is last`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const streams = [createReadableStream([["", "", "z"]]), filter]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [["", "", "z"]]); + deepStrictEqual(filter.result().value, {}); +}); + +// --- csvInjectHeaderStream injects header exactly once --- +test(`${variant}: csvInjectHeaderStream injects header exactly once`, async (_t) => { + const streams = [ + createReadableStream([ + ["1", "2"], + ["3", "4"], + ["5", "6"], + ]), + csvInjectHeaderStream({ header: ["a", "b"] }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ["5", "6"], + ]); +}); + +// --- csvParseStream chunkSize ready/join paths --- +test(`${variant}: csvParseStream single input chunk reaching chunkSize processes immediately`, async (_t) => { + const streams = [ + createReadableStream(["a,b,c,d,e\r\n", "1,2,3,4,5\r\n"]), + csvParseStream({ chunkSize: 5 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b", "c", "d", "e"], + ["1", "2", "3", "4", "5"], + ]); +}); + +test(`${variant}: csvParseStream below chunkSize joins on flush`, async (_t) => { + const streams = [ + createReadableStream(["a,", "b\r\n", "1,", "2\r\n"]), + csvParseStream({ chunkSize: 1000 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ]); +}); + +test(`${variant}: csvParseStream default resultKey is csvErrors`, async (_t) => { + const streams = [createReadableStream("a,b\r\n"), csvParseStream()]; + const result = await pipeline(streams); + strictEqual(streams[1].result().key, "csvErrors"); + deepStrictEqual(result.csvErrors, {}); +}); + +// --- csvFormatStream multi-char delimiter: each first-char formula trigger --- +test(`${variant}: csvFormatStream multi-char delimiter quotes leading equals`, async (_t) => { + const streams = [ + createReadableStream([["=x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"=x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading plus`, async (_t) => { + const streams = [ + createReadableStream([["+x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"+x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading minus`, async (_t) => { + const streams = [ + createReadableStream([["-x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"-x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading at-sign`, async (_t) => { + const streams = [ + createReadableStream([["@x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"@x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading space`, async (_t) => { + const streams = [ + createReadableStream([[" x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '" x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading BOM`, async (_t) => { + const streams = [ + createReadableStream([["x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter does NOT quote plain leading char`, async (_t) => { + const streams = [ + createReadableStream([["xyz", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), "xyz::ok\r\n"); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes trailing space`, async (_t) => { + const streams = [ + createReadableStream([["trail ", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"trail "::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes on newline`, async (_t) => { + const a = await streamToString( + pipejoin([ + createReadableStream([["a\nb", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]), + ); + strictEqual(a, '"a\nb"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes on carriage return`, async (_t) => { + const b = await streamToString( + pipejoin([ + createReadableStream([["a\rb", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]), + ); + strictEqual(b, '"a\rb"::ok\r\n'); +}); + +// --- csvFormatStream single-char delimiter: each first-char formula trigger --- +test(`${variant}: csvFormatStream single-char delimiter quotes leading at-sign`, async (_t) => { + const streams = [createReadableStream([["@x", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"@x",ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter quotes leading plus`, async (_t) => { + const streams = [createReadableStream([["+x", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"+x",ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter quotes leading minus`, async (_t) => { + const streams = [createReadableStream([["-x", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"-x",ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter quotes leading equals`, async (_t) => { + const streams = [createReadableStream([["=x", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"=x",ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter does NOT quote plain value`, async (_t) => { + const streams = [createReadableStream([["xyz", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), "xyz,ok\r\n"); +}); + +test(`${variant}: csvFormatStream single-char delimiter quotes trailing space`, async (_t) => { + const streams = [createReadableStream([["trail ", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"trail ",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should quote field with leading BOM`, async (_t) => { + const streams = [createReadableStream([["bom", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"bom",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should quote field with carriage return`, async (_t) => { + const streams = [createReadableStream([["a\rb", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"a\rb",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should format Date as ISO string`, async (_t) => { + const d = new Date("2024-01-15T10:30:00.000Z"); + const streams = [createReadableStream([[d, "ok"]]), csvFormatStream()]; + strictEqual( + await streamToString(pipejoin(streams)), + "2024-01-15T10:30:00.000Z,ok\r\n", + ); +}); + +test(`${variant}: csvFormatStream should format number via String`, async (_t) => { + const streams = [createReadableStream([[42, 3.14]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), "42,3.14\r\n"); +}); + +test(`${variant}: csvFormatStream should format boolean via String in slow path`, async (_t) => { + const streams = [createReadableStream([[true, false]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), "true,false\r\n"); +}); + +test(`${variant}: csvFormatStream should quote negative number string`, async (_t) => { + const streams = [createReadableStream([[-42, "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"-42",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should format null and undefined as empty`, async (_t) => { + const streams = [ + createReadableStream([[null, undefined, "x"]]), + csvFormatStream(), + ]; + strictEqual(await streamToString(pipejoin(streams)), ",,x\r\n"); +}); + +test(`${variant}: csvFormatStream custom escape escapes escape and quote chars`, async (_t) => { + const streams = [ + createReadableStream([['a"b\\c', "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"a\\"b\\\\c",ok\r\n'); +}); + +test(`${variant}: csvFormatStream custom escape escapes only escape char when no quote`, async (_t) => { + const streams = [ + createReadableStream([["a\\b,c", "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"a\\\\b,c",ok\r\n'); +}); + +test(`${variant}: csvFormatStream batch boundary flushes at 64 rows`, async (_t) => { + const rows = []; + for (let i = 0; i < 70; i++) rows.push([String(i), "x"]); + const output = await streamToString( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + const lines = output.split("\r\n").filter((l) => l.length > 0); + strictEqual(lines.length, 70); + strictEqual(lines[0], "0,x"); + strictEqual(lines[69], "69,x"); +}); + +test(`${variant}: csvFormatStream custom newlineChar separator`, async (_t) => { + const output = await streamToString( + pipejoin([ + createReadableStream([ + ["1", "2"], + ["3", "4"], + ]), + csvFormatStream({ newlineChar: "\n" }), + ]), + ); + strictEqual(output, "1,2\n3,4\n"); +}); + +test(`${variant}: csvFormatStream custom quoteChar`, async (_t) => { + const output = await streamToString( + pipejoin([ + createReadableStream([["a,b", "ok"]]), + csvFormatStream({ quoteChar: "'" }), + ]), + ); + strictEqual(output, "'a,b',ok\r\n"); +}); + +// --- Quoted-field post-quote newline dispatch: length 1 / 2 / >2 --- +test(`${variant}: csvQuotedParser quoted field then LF newline (length 1)`, (_t) => { + const result = csvQuotedParser('"a"\n"b"\n', { newlineChar: "\n" }, true); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser quoted field then CRLF newline (length 2)`, (_t) => { + const result = csvQuotedParser( + '"a"\r\n"b"\r\n', + { newlineChar: "\r\n" }, + true, + ); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser quoted field then lone CR is not a CRLF newline`, (_t) => { + const result = csvQuotedParser('"a"\rb,c\r\n', { newlineChar: "\r\n" }, true); + strictEqual(result.rows.length, 1); + strictEqual(result.rows[0][0], "a"); +}); + +test(`${variant}: csvQuotedParser quoted field then 3-char newline (length > 2)`, (_t) => { + const result = csvQuotedParser('"a"~|~"b"~|~', { newlineChar: "~|~" }, true); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser quoted field then partial 3-char newline is garbage`, (_t) => { + const result = csvQuotedParser('"a"~|x~|~', { newlineChar: "~|~" }, true); + strictEqual(result.rows.length, 1); + strictEqual(result.rows[0][0], "a"); +}); + +test(`${variant}: csvQuotedParser custom escape quoted field then LF newline`, (_t) => { + const result = csvQuotedParser( + '"a"\n"b"\n', + { newlineChar: "\n", escapeChar: "\\" }, + true, + ); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser custom escape quoted field then CRLF newline`, (_t) => { + const result = csvQuotedParser( + '"a"\r\n"b"\r\n', + { newlineChar: "\r\n", escapeChar: "\\" }, + true, + ); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser custom escape quoted field then 3-char newline`, (_t) => { + const result = csvQuotedParser( + '"a"~|~"b"~|~', + { newlineChar: "~|~", escapeChar: "\\" }, + true, + ); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser custom escape quoted field then lone CR not CRLF`, (_t) => { + const result = csvQuotedParser( + '"a"\rb,c\r\n', + { newlineChar: "\r\n", escapeChar: "\\" }, + true, + ); + strictEqual(result.rows.length, 1); + strictEqual(result.rows[0][0], "a"); +}); + +// --- csvCoerceValuesStream: iso8601 regex shape --- +test(`${variant}: csvCoerceValuesStream should coerce date with multi-digit fractional seconds`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15T10:30:00.123Z" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-01-15T10:30:00.123Z") }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce date with timezone offset without colon`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15T10:30:00+0530" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-01-15T10:30:00+0530") }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce date with timezone offset with colon`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15T10:30:00+05:30" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-01-15T10:30:00+05:30") }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce date with space separator and seconds`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15 10:30:45" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-01-15 10:30:45") }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce plain date-only value`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-12-31" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-12-31") }]); +}); + +test(`${variant}: csvCoerceValuesStream should keep date with non-digit fractional as string`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15T10:30:00.abc" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: "2024-01-15T10:30:00.abc" }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce exponent forms`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "1e+10", b: "2e-05", c: "1.5E3" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: 1e10, b: 2e-5, c: 1500 }]); +}); + +test(`${variant}: csvCoerceValuesStream should keep malformed exponent as string`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "1e", b: "1e+", c: "1.2.3" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: "1e", b: "1e+", c: "1.2.3" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce trailing non-number text`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "12abc", d: "2024-01-15extra" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: "12abc", d: "2024-01-15extra" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce date missing leading anchor`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "x2024-01-15" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: "x2024-01-15" }]); +}); + +test(`${variant}: csvCoerceValuesStream boolean first-char/length gating`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "trUE", b: "tree", c: "True", d: "truer" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: true, b: "tree", c: true, d: "truer" }]); +}); + +test(`${variant}: csvCoerceValuesStream should return null for empty string auto-coerce`, async (_t) => { + const output = await streamToArray( + pipejoin([createReadableStream([{ a: "" }]), csvCoerceValuesStream()]), + ); + deepStrictEqual(output, [{ a: null }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce single-digit zero and nine`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "0", b: "9" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: 0, b: 9 }]); +}); + +test(`${variant}: csvCoerceValuesStream explicit boolean lowercase compare`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "TRUE", b: "no", c: "true" }]), + csvCoerceValuesStream({ + columns: { a: "boolean", b: "boolean", c: "boolean" }, + }), + ]), + ); + deepStrictEqual(output, [{ a: true, b: false, c: true }]); +}); + +test(`${variant}: csvCoerceValuesStream explicit boolean Boolean for non-string`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: 1, b: 0 }]), + csvCoerceValuesStream({ columns: { a: "boolean", b: "boolean" } }), + ]), + ); + deepStrictEqual(output, [{ a: true, b: false }]); +}); + +test(`${variant}: csvCoerceValuesStream explicit json parses array`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "[1,2,3]" }]), + csvCoerceValuesStream({ columns: { a: "json" } }), + ]), + ); + deepStrictEqual(output, [{ a: [1, 2, 3] }]); +}); + +// --- csvDetectDelimitersStream quote boundary precision --- +test(`${variant}: csvDetectDelimitersStream detects quote closing at end of text`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("x,y\nz,'w'"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects quote opening after CR-only newline`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a,b\r'c',d\r"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); + strictEqual(detect.result().value.newlineChar, "\r"); +}); + +test(`${variant}: csvDetectDelimitersStream detects quote closing before a delimiter`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("'a',b,c\n1,2,3\n"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects quote opening at text start`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("'a',b\n1,2\n"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects quote opening after newline`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a,b\n'c',d\n"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream does NOT detect a quote that never closes at a field end`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([ + createReadableStream("name,note\n'x,it's fine\nhello,world\n"), + detect, + ]); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream sets escapeChar equal to detected quoteChar`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("'a','b'\n1,2\n"), detect]); + strictEqual(detect.result().value.escapeChar, "'"); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream picks tab over comma by precedence`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a\tb,c\n1\t2,3\n"), detect]); + strictEqual(detect.result().value.delimiterChar, "\t"); +}); + +test(`${variant}: csvDetectDelimitersStream picks pipe over semicolon and comma`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a|b;c,d\n1|2;3,4\n"), detect]); + strictEqual(detect.result().value.delimiterChar, "|"); +}); + +test(`${variant}: csvDetectDelimitersStream picks semicolon over comma`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a;b,c\n1;2,3\n"), detect]); + strictEqual(detect.result().value.delimiterChar, ";"); +}); + +test(`${variant}: csvDetectDelimitersStream defaults to double-quote when none present`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a,b,c\n1,2,3\n"), detect]); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +// --- csvFormatStream: formatRowSlow null and empty-string branches --- +test(`${variant}: csvFormatStream should format null field alongside a number (formatRowSlow null branch)`, async (_t) => { + // null alongside a number forces isSimpleRow to return false (number is non-string), + // so formatRowSlow is called; the null field hits the val==null branch (parts[i]="") + const streams = [createReadableStream([[null, 42]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, ",42\r\n"); +}); + +test(`${variant}: csvFormatStream should format empty-string field alongside a quoting-required value (formatRowSlow empty-string branch)`, async (_t) => { + // A field needing quoting makes isSimpleRow return false, so formatRowSlow is called; + // the empty-string field hits the val.length===0 branch (parts[i]="") + const streams = [ + createReadableStream([["hello, world", ""]]), + csvFormatStream(), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"hello, world",\r\n'); +}); + +// --- csvCoerceValuesStream: coerceToType date with invalid date string --- +test(`${variant}: csvCoerceValuesStream explicit date type should return original string for invalid date`, async (_t) => { + // coerceToType(val, "date") path: Number.isNaN(d.getTime()) === true → return val + const streams = [ + createReadableStream([{ val: "not-a-date" }]), + csvCoerceValuesStream({ columns: { val: "date" } }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ val: "not-a-date" }]); +}); + +// --- csvFormatStream: custom escape wrapQuote with no escape char in value --- +test(`${variant}: csvFormatStream custom escape should quote value with delimiter but no escape char`, async (_t) => { + // wrapQuote custom-escape path: value contains delimiter (needs quoting) + // but does NOT contain escapeChar → takes the "value" branch of the includes check + const streams = [ + createReadableStream([["a,b", "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + const output = await streamToString(pipejoin(streams)); + // No backslash in "a,b", so no escapeChar escaping; only quoteChar wrapping + strictEqual(output, '"a,b",ok\r\n'); +}); + +// ===================================================================== +// Mutation-hardening tests (kill surviving Stryker mutants) +// ===================================================================== + +// --- csvQuotedParser: precise idx / numCols tracking --- +test(`${variant}: csvQuotedParser reports idx equal to number of rows (default path)`, (_t) => { + const result = csvQuotedParser("a,b\r\nc,d\r\ne,f\r\n"); + strictEqual(result.idx, 3); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvQuotedParser reports idx for quoted-field rows`, (_t) => { + // All rows have a leading quoted field so each row goes through the quoted + // branch idx++ (escapeIsQuote=true). idx must equal the row count. + const result = csvQuotedParser('"a",b\r\n"c",d\r\n"e",f\r\n'); + strictEqual(result.idx, 3); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ["e", "f"], + ]); +}); + +test(`${variant}: csvQuotedParser reports idx for custom-escape quoted rows`, (_t) => { + const result = csvQuotedParser( + '"a",b\r\n"c",d\r\n', + { escapeChar: "\\" }, + true, + ); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvQuotedParser flush short row truncates to fi (escapeIsQuote)`, (_t) => { + // numCols=3 from first row; second row quoted single field on flush -> length 1 + const result = csvQuotedParser('a,b,c\r\n"d"', {}, true); + strictEqual(result.numCols, 3); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows[1], ["d"]); + strictEqual(result.rows[1].length, 1); +}); + +test(`${variant}: csvQuotedParser flush short row truncates to fi (custom escape)`, (_t) => { + const result = csvQuotedParser('a,b,c\r\n"d"', { escapeChar: "\\" }, true); + strictEqual(result.numCols, 3); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows[1], ["d"]); + strictEqual(result.rows[1].length, 1); +}); + +test(`${variant}: csvQuotedParser unterminated quote short row truncates to fi (escapeIsQuote)`, (_t) => { + // numCols=3; an unterminated quoted field on flush must produce exactly 1 field + const result = csvQuotedParser('a,b,c\r\n"unterminated', {}, true); + strictEqual(result.numCols, 3); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows[1], ["unterminated"]); + strictEqual(result.rows[1].length, 1); + deepStrictEqual(result.errors.UnterminatedQuote.idx, [1]); +}); + +test(`${variant}: csvQuotedParser unterminated quote short row truncates to fi (custom escape)`, (_t) => { + const result = csvQuotedParser( + 'a,b,c\r\n"unterminated', + { escapeChar: "\\" }, + true, + ); + strictEqual(result.numCols, 3); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows[1], ["unterminated"]); + strictEqual(result.rows[1].length, 1); + deepStrictEqual(result.errors.UnterminatedQuote.idx, [1]); +}); + +test(`${variant}: csvQuotedParser unterminated quote establishes numCols when first (escapeIsQuote)`, (_t) => { + // First (and only) row is an unterminated quote on flush: numCols becomes fi (1) + const result = csvQuotedParser('"unterminated', {}, true); + strictEqual(result.numCols, 1); + strictEqual(result.idx, 1); + deepStrictEqual(result.rows, [["unterminated"]]); +}); + +test(`${variant}: csvQuotedParser unterminated quote establishes numCols when first (custom escape)`, (_t) => { + const result = csvQuotedParser('"unterminated', { escapeChar: "\\" }, true); + strictEqual(result.numCols, 1); + strictEqual(result.idx, 1); + deepStrictEqual(result.rows, [["unterminated"]]); +}); + +test(`${variant}: csvQuotedParser unterminated quote not flushing returns rowStart tail (escapeIsQuote)`, (_t) => { + // not flushing: ctx.tail must be text.substring(rowStart), preserving the whole + // unterminated row including its quote. + const result = csvQuotedParser('a,b\r\n"unterm', {}, false); + strictEqual(result.tail, '"unterm'); + strictEqual(result.idx, 1); +}); + +test(`${variant}: csvQuotedParser unterminated quote not flushing returns rowStart tail (custom escape)`, (_t) => { + const result = csvQuotedParser( + 'a,b\r\nx,"unterm', + { escapeChar: "\\" }, + false, + ); + strictEqual(result.tail, 'x,"unterm'); + strictEqual(result.idx, 1); +}); + +test(`${variant}: csvQuotedParser quoted first field establishes numCols (escapeIsQuote)`, (_t) => { + // First row begins with a quoted field then newline path establishes numCols. + const result = csvQuotedParser('"a","b"\r\nc,d\r\n'); + strictEqual(result.numCols, 2); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvQuotedParser quoted first field establishes numCols (custom escape)`, (_t) => { + const result = csvQuotedParser('"a","b"\r\nc,d\r\n', { escapeChar: "\\" }); + strictEqual(result.numCols, 2); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvQuotedParser quoted-field row after established numCols truncates surplus (escapeIsQuote)`, (_t) => { + // numCols=2; a later quoted-first row with 3 fields must be truncated? No — + // surplus is kept by indexOf path; assert exact when row has fewer fields. + const result = csvQuotedParser('"a","b"\r\n"c"\r\n'); + strictEqual(result.rows[1].length, 1); + deepStrictEqual(result.rows[1], ["c"]); + strictEqual(result.idx, 2); +}); + +// --- csvQuotedParser: garbage-after-closing-quote keeps extra fields --- +test(`${variant}: csvQuotedParser keeps garbage after closing quote as separate field (escapeIsQuote)`, (_t) => { + const result = csvQuotedParser('"a"x,b\r\n'); + deepStrictEqual(result.rows, [["a", "x", "b"]]); +}); + +test(`${variant}: csvQuotedParser keeps garbage after closing quote as separate field (custom escape)`, (_t) => { + const result = csvQuotedParser('"a"x,b\r\n', { escapeChar: "\\" }); + deepStrictEqual(result.rows, [["a", "x", "b"]]); +}); + +// --- csvQuotedParser: quoted field at exact end of input (pos >= len) --- +test(`${variant}: csvQuotedParser quoted field at exact end of input keeps field (escapeIsQuote)`, (_t) => { + const result = csvQuotedParser('a,"b"', {}, true); + deepStrictEqual(result.rows, [["a", "b"]]); + strictEqual(result.idx, 1); +}); + +test(`${variant}: csvQuotedParser quoted field at exact end of input keeps field (custom escape)`, (_t) => { + const result = csvQuotedParser('a,"b"', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [["a", "b"]]); + strictEqual(result.idx, 1); +}); + +// --- csvQuotedParser: delimiter immediately after closing quote (lastWasDelimiter) --- +test(`${variant}: csvQuotedParser quoted field then delimiter then trailing flush field (escapeIsQuote)`, (_t) => { + // "a", then delimiter sets lastWasDelimiter=true; flush must add a trailing "". + const result = csvQuotedParser('"a",', {}, true); + deepStrictEqual(result.rows, [["a", ""]]); + strictEqual(result.idx, 1); +}); + +test(`${variant}: csvQuotedParser quoted field then delimiter then trailing flush field (custom escape)`, (_t) => { + const result = csvQuotedParser('"a",', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [["a", ""]]); + strictEqual(result.idx, 1); +}); + +// --- csvQuotedParser: newline-length boundary (CRLF needs second char) --- +test(`${variant}: csvQuotedParser quoted field then CR without LF is garbage not newline (escapeIsQuote)`, (_t) => { + // nc===CR but pos+1 is end -> not a CRLF; CR treated as garbage, kept inline. + const result = csvQuotedParser('"a"\r', { newlineChar: "\r\n" }, true); + // "a" then lone CR (garbage after quote) at end of input + deepStrictEqual(result.rows, [["a", "\r"]]); +}); + +test(`${variant}: csvQuotedParser quoted field then CR without LF is garbage not newline (custom escape)`, (_t) => { + const result = csvQuotedParser( + '"a"\r', + { newlineChar: "\r\n", escapeChar: "\\" }, + true, + ); + deepStrictEqual(result.rows, [["a", "\r"]]); +}); + +// --- csvParseInline numCols>0 fast path: idx, malformed, surplus --- +test(`${variant}: csvQuotedParser fast unquoted path reports idx for many rows`, (_t) => { + // numCols established as 2 by first row, then fast path handles the rest. + const result = csvQuotedParser("a,b\r\n1,2\r\n3,4\r\n5,6\r\n7,8\r\n"); + strictEqual(result.idx, 5); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows[4], ["7", "8"]); +}); + +test(`${variant}: csvQuotedParser fast path malformed too-few columns split fallback`, (_t) => { + // numCols=3; a row with only 2 fields uses the split fallback, keeping 2 fields. + const result = csvQuotedParser("a,b,c\r\n1,2\r\n"); + deepStrictEqual(result.rows[1], ["1", "2"]); + strictEqual(result.rows[1].length, 2); +}); + +test(`${variant}: csvQuotedParser fast path surplus columns kept separate via split`, (_t) => { + // numCols=2; a row with 4 fields must keep all 4 (split fallback), not merge. + const result = csvQuotedParser("a,b\r\n1,2,3,4\r\n"); + deepStrictEqual(result.rows[1], ["1", "2", "3", "4"]); + strictEqual(result.rows[1].length, 4); +}); + +test(`${variant}: csvQuotedParser fast path exact column count keeps fields`, (_t) => { + const result = csvQuotedParser("a,b,c\r\n1,2,3\r\n4,5,6\r\n"); + deepStrictEqual(result.rows[1], ["1", "2", "3"]); + deepStrictEqual(result.rows[2], ["4", "5", "6"]); + strictEqual(result.idx, 3); +}); + +test(`${variant}: csvQuotedParser fast path partial last row without newline returns tail`, (_t) => { + // numCols=2; trailing partial row (no newline) must be returned as tail. + const result = csvQuotedParser("a,b\r\n1,2\r\n3,4", {}, false); + strictEqual(result.tail, "3,4"); + strictEqual(result.idx, 2); +}); + +// --- csvUnquotedParser: idx / numCols --- +test(`${variant}: csvUnquotedParser reports idx and numCols`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2\r\n3,4\r\n"); + strictEqual(result.idx, 3); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser flush increments idx for trailing row`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2", {}, true); + strictEqual(result.idx, 2); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser preserves explicit idx and numCols options`, (_t) => { + const result = csvUnquotedParser("1,2\r\n", { idx: 5, numCols: 2 }); + strictEqual(result.idx, 6); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser no trailing row when input ends with newline`, (_t) => { + const result = csvUnquotedParser("a,b\r\n", {}, true); + strictEqual(result.idx, 1); + deepStrictEqual(result.rows, [["a", "b"]]); +}); + +// --- csvParseStream: idx-sensitive error reporting through the stream --- +test(`${variant}: csvParseStream reports unterminated quote idx after several rows`, async (_t) => { + const streams = [ + createReadableStream('a,b\r\nc,d\r\ne,f\r\n"unterminated'), + csvParseStream(), + ]; + const result = await pipeline(streams); + // 3 complete rows (idx 0,1,2) then unterminated at idx 3 + deepStrictEqual(result.csvErrors.UnterminatedQuote.idx, [3]); +}); + +test(`${variant}: csvParseStream reports unterminated quote idx after several rows (custom escape)`, async (_t) => { + const streams = [ + createReadableStream('a,b\r\nc,d\r\ne,f\r\nx,"unterminated'), + csvParseStream({ escapeChar: "\\" }), + ]; + const result = await pipeline(streams); + deepStrictEqual(result.csvErrors.UnterminatedQuote.idx, [3]); +}); + +// --- findRowEnd via csvDetectHeaderStream --- +test(`${variant}: csvDetectHeaderStream handles quoted header with escaped quote and embedded newline`, async (_t) => { + // Header field is quoted, contains an escaped "" and a newline; findRowEnd must + // skip the in-quote newline and the escaped-quote pair, terminating at the real + // row-end newline so the header parses as two columns. + const hdr = csvDetectHeaderStream(); + const streams = [ + createReadableStream('"a""b\r\nc",second\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ['a"b\r\nc', "second"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream custom-escape header skips escaped quote in quoted field`, async (_t) => { + // escapeChar='\\': inside the quoted header field, \" is an escaped quote, so the + // field stays open past it; findRowEnd's odd-run lookback must keep scanning. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const streams = [ + createReadableStream('"a\\"b,c",second\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ['a"b,c', "second"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream custom-escape even run closes quote in findRowEnd`, async (_t) => { + // "\\\\" is an even run of escape chars => the quote IS a real closing quote, + // so the delimiter after it splits the header field. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const streams = [ + createReadableStream('"a\\\\",b\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ["a\\", "b"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream delimiter inside header advances fieldStart`, async (_t) => { + // Ensures findRowEnd's delimiter branch (pos += delimiterLength; fieldStart=pos) + // is exercised: a quoted field appears after a delimiter so it must still be + // recognized as a field-start quote. + const hdr = csvDetectHeaderStream(); + const streams = [ + createReadableStream('x,"q,uoted"\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ["x", "q,uoted"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream multi-char delimiter inside findRowEnd`, async (_t) => { + const hdr = csvDetectHeaderStream({ delimiterChar: "::" }); + const streams = [ + createReadableStream('"a::b"::c\r\n1::2\r\n'), + hdr, + csvParseStream({ delimiterChar: "::" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ["a::b", "c"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +// --- csvDetectDelimitersStream: chunkSize threshold and buffer reset --- +test(`${variant}: csvDetectDelimitersStream waits until buffer reaches chunkSize`, async (_t) => { + // chunkSize 8: first chunk (len 6) must NOT trigger detection; second chunk does. + const detect = csvDetectDelimitersStream({ chunkSize: 8 }); + const streams = [createReadableStream(["a;b\r\n", "c;d\r\n"]), detect]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + strictEqual(detect.result().value.delimiterChar, ";"); + strictEqual(output, "a;b\r\nc;d\r\n"); +}); + +test(`${variant}: csvDetectDelimitersStream emits buffered content exactly once`, async (_t) => { + // Make sure buffer is cleared after detection so content is not duplicated. + const detect = csvDetectDelimitersStream({ chunkSize: 4 }); + const streams = [ + createReadableStream(["a|b\r\n", "rest1\r\n", "rest2\r\n"]), + detect, + ]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + strictEqual(detect.result().value.delimiterChar, "|"); + strictEqual(output, "a|b\r\nrest1\r\nrest2\r\n"); +}); + +test(`${variant}: csvDetectDelimitersStream detect returns true sets detected so later chunks pass through`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 4 }); + const streams = [createReadableStream(["a,b\r\n", "later\r\n"]), detect]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + strictEqual(output, "a,b\r\nlater\r\n"); +}); + +// --- unescapeCustom boundary (i + 1 < len) via custom-escape parse --- +test(`${variant}: csvParseStream custom-escape unescapeCustom handles escaped escape and escaped quote`, async (_t) => { + // Content (between the outer quotes) is: \ \ x \ " y + // - "\\\\" is an escaped escape -> single backslash + // - "\\\"" is an escaped quote -> literal quote (does not close the field) + // The final unescaped quote closes the field. unescapeCustom must collapse both. + const streams = [ + createReadableStream('"\\\\x\\"y",z\r\n'), + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [['\\x"y', "z"]]); +}); + +test(`${variant}: csvParseStream custom-escape escaped quote inside field unescaped once`, async (_t) => { + const streams = [ + createReadableStream('"a\\"b\\"c",d\r\n'), + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [['a"b"c', "d"]]); +}); + +// --- csvParseStream fast-path: text.indexOf(quoteChar) === -1 guard --- +test(`${variant}: csvParseStream switches off fast path when a quote appears in later row`, async (_t) => { + // First row establishes numCols (no quotes); a later row contains a quoted field + // with an embedded delimiter, which must NOT be split. + const streams = [ + createReadableStream('a,b\r\n"x,y",z\r\n'), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["x,y", "z"], + ]); +}); + +// --- csvParseStream: first-row detection when numCols===0 and a quote exists later in row --- +test(`${variant}: csvParseStream first row with quote does not use split detection`, async (_t) => { + // First row itself contains a quoted field with an embedded delimiter. + const streams = [ + createReadableStream('"a,b",c\r\nd,e\r\n'), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a,b", "c"], + ["d", "e"], + ]); + deepStrictEqual(streams[1].result().value, {}); +}); + +// --- csvParseStream: regular indexOf path, nextNl < nextDelim and trailing-delim --- +test(`${variant}: csvParseStream quoted first field then plain rows exercise regular path`, async (_t) => { + // A quoted first field forces fi!==0 entry so the regular indexOf path runs for + // the remaining fields/rows. + const streams = [ + createReadableStream('"a",b,c\r\nd,e,f\r\n'), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b", "c"], + ["d", "e", "f"], + ]); +}); + +// --- csvSteamifyParser resolveOptions: delimiterCharSingle / newlineCharSingle --- +test(`${variant}: csvParseStream multi-char delimiter is not treated as single`, async (_t) => { + // delimiterCharSingle=false path: post-quote dispatch must use startsWith. + const streams = [ + createReadableStream('"a"::"b"::c\r\n'), + csvParseStream({ delimiterChar: "::" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [["a", "b", "c"]]); +}); + +test(`${variant}: csvParseStream 2-char newline second-char check after quoted field`, async (_t) => { + // newlineCharSingle is the 2nd char of a 2-char newline; a quoted field then the + // 2-char newline must split rows, and a partial first char must not. + const streams = [ + createReadableStream('"a",b\r\n"c",d\r\n'), + csvParseStream({ newlineChar: "\r\n" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- csvParseStream: typeof chunk string vs Buffer --- +test(`${variant}: csvParseStream coerces Buffer chunk via toString`, async (_t) => { + const streams = [ + createReadableStream([Buffer.from("a,b\r\nc,d\r\n")]), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- csvParseStream: buffer concat across chunks (buffer.length>0 ? buffer+str) --- +test(`${variant}: csvParseStream concatenates buffered tail with next chunk`, async (_t) => { + // A row split across two chunks at chunkSize=1 so the second chunk must be + // prefixed with the buffered partial row. + const streams = [ + createReadableStream(["a,b\r\n1,", "2\r\n"]), + csvParseStream({ chunkSize: 1 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ]); +}); + +// --- csvParseStream: safety limit boundary (text.length > fieldMaxSize*2) --- +test(`${variant}: csvParseStream accepts text at exactly fieldMaxSize*2`, async (_t) => { + // length === fieldMaxSize*2 must NOT throw ( strict greater-than ). + const fieldMaxSize = 8; + const text = "x".repeat(fieldMaxSize * 2); // 16, no newline -> one field on flush + const streams = [ + createReadableStream(text), + csvParseStream({ fieldMaxSize }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [[text]]); +}); + +// --- csvParseStream ready-path: inputLen < chunkSize and single vs join --- +test(`${variant}: csvParseStream single chunk equal to chunkSize processes without join`, async (_t) => { + const row = "a,b\r\n"; // length 5 + const streams = [ + createReadableStream([row]), + csvParseStream({ chunkSize: 5 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [["a", "b"]]); +}); + +test(`${variant}: csvParseStream multiple chunks joined when crossing chunkSize`, async (_t) => { + const streams = [ + createReadableStream(["a,", "b\r\n", "c,d\r\n"]), + csvParseStream({ chunkSize: 3 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- csvRemoveEmptyRowsStream: zero-length row returns true immediately --- +test(`${variant}: csvRemoveEmptyRowsStream treats zero-length array as empty (l===0 branch)`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const streams = [createReadableStream([[], ["a"]]), filter]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [["a"]]); + deepStrictEqual(filter.result().value.EmptyRow.idx, [0]); +}); + +// --- csvArrayToObject: defineProperty descriptor flags --- +test(`${variant}: csvArrayToObject __proto__ column is writable, enumerable, configurable`, async (_t) => { + const streams = [ + createReadableStream([["v1", "v2"]]), + csvArrayToObject({ headers: ["__proto__", "b"] }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + const obj = output[0]; + const desc = Object.getOwnPropertyDescriptor(obj, "__proto__"); + ok(desc, "own __proto__ descriptor exists"); + strictEqual(desc.value, "v1"); + strictEqual(desc.enumerable, true); + strictEqual(desc.writable, true); + strictEqual(desc.configurable, true); + // enumerable means it shows up in keys + deepStrictEqual(Object.keys(obj), ["__proto__", "b"]); +}); + +test(`${variant}: csvArrayToObject constructor column stored as own data property`, async (_t) => { + const streams = [ + createReadableStream([["v1", "v2"]]), + csvArrayToObject({ headers: ["constructor", "b"] }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + const obj = output[0]; + const desc = Object.getOwnPropertyDescriptor(obj, "constructor"); + ok(desc); + strictEqual(desc.value, "v1"); + strictEqual(desc.enumerable, true); +}); + +// --- csvCoerceValuesStream: autoCoerce boolean gating (len & first-char) --- +test(`${variant}: csvCoerceValuesStream autoCoerce true requires length 4 and t/T`, async (_t) => { + const streams = [ + createReadableStream([{ a: "true", b: "True", c: "trueX", d: "rue" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // "true"->true, "True"->true, "trueX" (len 5, not false) stays string, "rue" stays + strictEqual(output[0].a, true); + strictEqual(output[0].b, true); + strictEqual(output[0].c, "trueX"); + strictEqual(output[0].d, "rue"); +}); + +test(`${variant}: csvCoerceValuesStream autoCoerce false requires length 5 and f/F`, async (_t) => { + const streams = [ + createReadableStream([{ a: "false", b: "False", c: "fALSE", d: "falsey" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output[0].a, false); + strictEqual(output[0].b, false); + strictEqual(output[0].c, false); + strictEqual(output[0].d, "falsey"); +}); + +test(`${variant}: csvCoerceValuesStream autoCoerce digit and minus start numeric path`, async (_t) => { + const streams = [ + createReadableStream([{ a: "42", b: "-7", c: "9", d: "0" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output[0].a, 42); + strictEqual(output[0].b, -7); + strictEqual(output[0].c, 9); + strictEqual(output[0].d, 0); +}); + +test(`${variant}: csvCoerceValuesStream autoCoerce JSON only for braces/brackets`, async (_t) => { + const streams = [ + createReadableStream([{ a: '{"x":1}', b: "[1,2]", c: "plain" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output[0].a, { x: 1 }); + deepStrictEqual(output[0].b, [1, 2]); + strictEqual(output[0].c, "plain"); +}); + +test(`${variant}: csvCoerceValuesStream autoCoerce invalid JSON returns the original string (catch returns val)`, async (_t) => { + const streams = [ + createReadableStream([{ a: "{not json", b: "[oops" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output[0].a, "{not json"); + strictEqual(output[0].b, "[oops"); +}); + +test(`${variant}: csvCoerceValuesStream coerceToType invalid json returns original string (catch returns val)`, async (_t) => { + const streams = [ + createReadableStream([{ a: "{not json" }]), + csvCoerceValuesStream({ columns: { a: "json" } }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output[0].a, "{not json"); +}); + +// --- autoCoerce iso8601 anchoring (^ and $) --- +test(`${variant}: csvCoerceValuesStream iso date requires full anchor (trailing junk stays string)`, async (_t) => { + const streams = [ + createReadableStream([{ a: "2020-01-02xyz", b: "2020-01-02" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // trailing junk -> regex (anchored) fails, stays string + strictEqual(output[0].a, "2020-01-02xyz"); + ok(output[0].b instanceof Date); +}); + +test(`${variant}: csvCoerceValuesStream iso date with seconds-and-fraction group present`, async (_t) => { + const streams = [ + createReadableStream([{ a: "2020-01-02T03:04:05.678Z" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + ok(output[0].a instanceof Date); + strictEqual( + output[0].a.getTime(), + new Date("2020-01-02T03:04:05.678Z").getTime(), + ); +}); + +// --- csvFormatStream: delimiterSingle vs multi, scanNeedsQuote loops --- +test(`${variant}: csvFormatStream single-char delimiter quotes interior delimiter only on actual delimiter`, async (_t) => { + const streams = [ + createReadableStream([["a;b", "c,d"]]), + csvFormatStream(), // delimiter "," + ]; + const output = await streamToString(pipejoin(streams)); + // "a;b" has no comma -> not quoted; "c,d" has comma -> quoted + strictEqual(output, 'a;b,"c,d"\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes value containing the delimiter substring`, async (_t) => { + const streams = [ + createReadableStream([["a::b", "plain"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a::b"::plain\r\n'); +}); + +// --- csvFormatStream: batch boundary at exactly 64 --- +test(`${variant}: csvFormatStream emits when batch reaches exactly 64 rows`, async (_t) => { + const rows = []; + for (let i = 0; i < 64; i++) rows.push([`r${i}`, "x"]); + // Collect each enqueued chunk separately + const chunks = await streamToArray( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + // With exactly 64 rows, the transform emits one chunk of 64 rows and flush emits nothing. + strictEqual(chunks.length, 1); + const lines = chunks[0].split("\r\n").filter((l) => l.length > 0); + strictEqual(lines.length, 64); +}); + +test(`${variant}: csvFormatStream below 64 rows emits only on flush`, async (_t) => { + const rows = []; + for (let i = 0; i < 10; i++) rows.push([`r${i}`, "x"]); + const chunks = await streamToArray( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + strictEqual(chunks.length, 1); + const lines = chunks[0].split("\r\n").filter((l) => l.length > 0); + strictEqual(lines.length, 10); +}); + +// --- csvFormatStream: isSimpleRow non-string / needs-quote detection --- +test(`${variant}: csvFormatStream isSimpleRow rejects rows with a quoting-needed string`, async (_t) => { + // A value needing a quote forces the slow path; assert correct quoting. + const streams = [ + createReadableStream([["plain", "needs\nquote"]]), + csvFormatStream(), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, 'plain,"needs\nquote"\r\n'); +}); + +test(`${variant}: csvFormatStream simple row of plain strings uses join fast path`, async (_t) => { + const streams = [createReadableStream([["a", "b", "c"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "a,b,c\r\n"); +}); + +// --- csvParseStream streamOptions default highWaterMark preserved --- +test(`${variant}: csvParseStream respects provided streamOptions override`, async (_t) => { + // Passing streamOptions should not break parsing (ObjectLiteral mutant guard). + const streams = [ + createReadableStream("a,b\r\nc,d\r\n"), + csvParseStream({}, { highWaterMark: 1 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// ===================================================================== +// Mutation-hardening tests, batch 2 +// ===================================================================== + +// --- findRowEnd: a quote is only an opener at field-start --- +test(`${variant}: csvDetectHeaderStream mid-field quote is not a quote opener`, async (_t) => { + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream('a"b,c\r\nd,e\r\n'), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, ['a"b', "c"]); + deepStrictEqual(out, [["d", "e"]]); +}); + +test(`${variant}: csvDetectHeaderStream quote opening right after a delimiter is honored`, async (_t) => { + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('a,"x,y"\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "x,y"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream keeps newline inside quoted header field`, async (_t) => { + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('"line1\r\nline2",b\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["line1\r\nline2", "b"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +// --- findRowEnd: custom-escape parity around a quoted newline --- +test(`${variant}: csvDetectHeaderStream custom escape keeps escaped quote then newline inside the field`, async (_t) => { + // "x\"y\r\nz" — the \" is an escaped quote (odd run of escapeChar), so the + // field stays open and the \r\n belongs to the header field, not the row end. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('"x\\"y\r\nz",b\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ['x"y\r\nz', "b"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream custom escape even run closes the quote before the newline`, async (_t) => { + // "a\\" — \\ is an escaped escape (even run), so the quote closes; the row + // ends at the very next newline. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('"a\\\\",b\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a\\", "b"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream custom escape backslash before quote keeps newline in field`, async (_t) => { + // A lone escaped quote right before the newline must keep the field open so + // the embedded \r\n does not terminate the header row. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('"a\\"\r\nb",c\r\nd,e\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ['a"\r\nb', "c"]); + deepStrictEqual(out, [["d", "e"]]); +}); + +// --- csvDetectDelimitersStream: isFieldStart / isFieldEnd --- +test(`${variant}: csvDetectDelimitersStream detects quote closing right before CR`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("'a'\r'b'\r"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream does not treat unopened quote as quoteChar`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("ab'cd,ef\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream buffers until a newline appears`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 3 }); + const out = await streamToString( + pipejoin([createReadableStream(["abcd", "e;f\r\n"]), detect]), + ); + strictEqual(out, "abcde;f\r\n"); + strictEqual(detect.result().value.delimiterChar, ";"); +}); + +// --- csvParseStream: 3-char newline dispatch after quoted field --- +test(`${variant}: csvParseStream quoted field then 3-char newline splits rows (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b###"c",d'), + csvParseStream({ newlineChar: "###" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvParseStream quoted field then partial 3-char newline is garbage (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a"##b'), + csvParseStream({ newlineChar: "###" }), + ]), + ); + deepStrictEqual(out, [["a", "##b"]]); +}); + +test(`${variant}: csvParseStream quoted field then 3-char newline splits rows (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b###"c",d'), + csvParseStream({ newlineChar: "###", escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvParseStream quoted field then partial 3-char newline is garbage (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a"##b'), + csvParseStream({ newlineChar: "###", escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [["a", "##b"]]); +}); + +// --- CRLF after quoted field requires both chars --- +test(`${variant}: csvParseStream quoted field then CRLF splits (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b\r\n"c",d\r\n'), + csvParseStream({ newlineChar: "\r\n" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvParseStream quoted field then CRLF splits (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b\r\n"c",d\r\n'), + csvParseStream({ newlineChar: "\r\n", escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- LF-only (length 1) newline after quoted field --- +test(`${variant}: csvParseStream quoted field then LF newline length 1 (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b\n"c",d\n'), + csvParseStream({ newlineChar: "\n" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvParseStream quoted field then LF newline length 1 (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b\n"c",d\n'), + csvParseStream({ newlineChar: "\n", escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- field exactly fieldMaxSize allowed (strict >) --- +test(`${variant}: csvParseStream allows quoted field exactly fieldMaxSize (escapeIsQuote)`, async (_t) => { + const val = "x".repeat(10); + const out = await streamToArray( + pipejoin([ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ fieldMaxSize: 10 }), + ]), + ); + deepStrictEqual(out, [[val, "y"]]); +}); + +test(`${variant}: csvParseStream allows custom-escape quoted field exactly fieldMaxSize`, async (_t) => { + const val = "x".repeat(10); + const out = await streamToArray( + pipejoin([ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ fieldMaxSize: 10, escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [[val, "y"]]); +}); + +// --- unterminated quote error message text --- +test(`${variant}: csvParseStream unterminated quote message (escapeIsQuote)`, async (_t) => { + const parse = csvParseStream(); + await pipeline([createReadableStream('"unterminated'), parse]); + strictEqual( + parse.result().value.UnterminatedQuote.message, + "Unterminated quoted field", + ); +}); + +test(`${variant}: csvParseStream unterminated quote message (custom escape)`, async (_t) => { + const parse = csvParseStream({ escapeChar: "\\" }); + await pipeline([createReadableStream('x,"unterminated'), parse]); + strictEqual( + parse.result().value.UnterminatedQuote.message, + "Unterminated quoted field", + ); +}); + +// --- not-flushing unterminated quote kept in tail across chunks --- +test(`${variant}: csvParseStream buffers unterminated quoted field across chunks (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(['"hel', 'lo",x\r\n']), + csvParseStream({ chunkSize: 1 }), + ]), + ); + deepStrictEqual(out, [["hello", "x"]]); +}); + +test(`${variant}: csvParseStream buffers unterminated quoted field across chunks (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(['"hel', 'lo",x\r\n']), + csvParseStream({ chunkSize: 1, escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [["hello", "x"]]); +}); + +// --- csvUnquotedParser numCols from first row --- +test(`${variant}: csvUnquotedParser numCols comes from first row not later rows`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2,3\r\n"); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser flush keeps first-row numCols`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2,3", {}, true); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows[1], ["1", "2", "3"]); +}); + +// --- csvFormatStream scanNeedsQuote triggers --- +test(`${variant}: csvFormatStream quotes a field that only ends with a space`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["ab ", "cd"]]), csvFormatStream()]), + ); + strictEqual(out, '"ab ",cd\r\n'); +}); + +test(`${variant}: csvFormatStream does not quote a field with only an interior space`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["a b", "cd"]]), csvFormatStream()]), + ); + strictEqual(out, "a b,cd\r\n"); +}); + +test(`${variant}: csvFormatStream quotes a field containing the quote char`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([['a"b', "cd"]]), csvFormatStream()]), + ); + strictEqual(out, '"a""b",cd\r\n'); +}); + +test(`${variant}: csvFormatStream quotes a field containing a carriage return`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["a\rb", "cd"]]), csvFormatStream()]), + ); + strictEqual(out, '"a\rb",cd\r\n'); +}); + +test(`${variant}: csvFormatStream quotes a field containing a line feed`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["a\nb", "cd"]]), csvFormatStream()]), + ); + strictEqual(out, '"a\nb",cd\r\n'); +}); + +test(`${variant}: csvFormatStream does not add a trailing empty field`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["a", "b", "c"]]), csvFormatStream()]), + ); + strictEqual(out, "a,b,c\r\n"); +}); + +test(`${variant}: csvFormatStream multi-char delimiter does not add trailing empty field`, async (_t) => { + const out = await streamToString( + pipejoin([ + createReadableStream([["a", "b", "c"]]), + csvFormatStream({ delimiterChar: "::" }), + ]), + ); + strictEqual(out, "a::b::c\r\n"); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes value containing the delimiter`, async (_t) => { + const out = await streamToString( + pipejoin([ + createReadableStream([["a::b", "x"]]), + csvFormatStream({ delimiterChar: "::" }), + ]), + ); + strictEqual(out, '"a::b"::x\r\n'); +}); + +// --- batch flush boundary (>= 64) --- +test(`${variant}: csvFormatStream does not flush at 63 rows`, async (_t) => { + const rows = []; + for (let i = 0; i < 63; i++) rows.push([String(i), "x"]); + const chunks = await streamToArray( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + strictEqual(chunks.length, 1); + strictEqual(chunks[0].split("\r\n").filter((l) => l.length > 0).length, 63); +}); + +test(`${variant}: csvFormatStream flushes at exactly 64 rows then a separate flush for extras`, async (_t) => { + const rows = []; + for (let i = 0; i < 65; i++) rows.push([String(i), "x"]); + const chunks = await streamToArray( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + strictEqual(chunks.length, 2); + strictEqual(chunks[0].split("\r\n").filter((l) => l.length > 0).length, 64); + strictEqual(chunks[1].split("\r\n").filter((l) => l.length > 0).length, 1); +}); + +// --- null/empty/number/Date coercion in formatRow --- +test(`${variant}: csvFormatStream formats null as empty field next to a quoted value`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([[null, "a,b"]]), csvFormatStream()]), + ); + strictEqual(out, ',"a,b"\r\n'); +}); + +test(`${variant}: csvFormatStream formats empty string as empty field next to a quoted value`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["", "a,b"]]), csvFormatStream()]), + ); + strictEqual(out, ',"a,b"\r\n'); +}); + +test(`${variant}: csvFormatStream coerces a number field via String`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([[42, "x"]]), csvFormatStream()]), + ); + strictEqual(out, "42,x\r\n"); +}); + +test(`${variant}: csvFormatStream coerces a Date field via toISOString`, async (_t) => { + const d = new Date("2020-01-02T03:04:05.000Z"); + const out = await streamToString( + pipejoin([createReadableStream([[d, "x"]]), csvFormatStream()]), + ); + strictEqual(out, "2020-01-02T03:04:05.000Z,x\r\n"); +}); + +// --- iso8601 regex anchors and seconds group --- +test(`${variant}: csvCoerceValuesStream iso date needs the ^ anchor`, async (_t) => { + // A leading space makes the anchored regex fail (stays a string), but a + // non-anchored regex would match the embedded date and `new Date(" 2020-01-02")` + // IS valid — so only the ^ anchor keeps this a string. + const out = await streamToArray( + pipejoin([ + createReadableStream([{ a: " 2020-01-02" }]), + csvCoerceValuesStream(), + ]), + ); + strictEqual(out[0].a, " 2020-01-02"); +}); + +test(`${variant}: csvCoerceValuesStream iso date needs the $ anchor`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([{ a: "2020-01-02junk" }]), + csvCoerceValuesStream(), + ]), + ); + strictEqual(out[0].a, "2020-01-02junk"); +}); + +test(`${variant}: csvCoerceValuesStream iso date with time but no seconds is still a Date`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([{ a: "2020-01-02T03:04" }]), + csvCoerceValuesStream(), + ]), + ); + ok(out[0].a instanceof Date); + strictEqual(out[0].a.getTime(), new Date("2020-01-02T03:04").getTime()); +}); + +test(`${variant}: csvCoerceValuesStream iso date with seconds is a Date`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([{ a: "2020-01-02T03:04:05" }]), + csvCoerceValuesStream(), + ]), + ); + ok(out[0].a instanceof Date); +}); + +// --- csvRemoveEmptyRowsStream l === 0 short-circuit --- +test(`${variant}: csvRemoveEmptyRowsStream drops a zero-length row`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const out = await streamToArray( + pipejoin([createReadableStream([[], ["keep"]]), filter]), + ); + deepStrictEqual(out, [["keep"]]); + deepStrictEqual(filter.result().value.EmptyRow.idx, [0]); +}); + +// --- csvArrayToObject constructor / normal key branches --- +test(`${variant}: csvArrayToObject stores a constructor column as an own data property`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([["v"]]), + csvArrayToObject({ headers: ["constructor"] }), + ]), + ); + const obj = out[0]; + strictEqual(Object.getOwnPropertyDescriptor(obj, "constructor").value, "v"); + deepStrictEqual(Object.keys(obj), ["constructor"]); +}); + +test(`${variant}: csvArrayToObject assigns a normal key directly`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([["v1", "v2"]]), + csvArrayToObject({ headers: ["a", "b"] }), + ]), + ); + deepStrictEqual(out[0], { a: "v1", b: "v2" }); +}); + +// ===================================================================== +// Mutation-hardening tests, batch 3 +// ===================================================================== + +// --- numCols reflects the FIRST row's field count, not later rows --- +test(`${variant}: csvQuotedParser numCols is first row count even when later rows differ`, (_t) => { + const result = csvQuotedParser("a,b\r\n1,2,3\r\n"); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvQuotedParser numCols first row via quoted first field`, (_t) => { + const result = csvQuotedParser('"a",b\r\n1,2,3\r\n'); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser numCols is first row count even when later rows differ`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2,3\r\n"); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser flush numCols is first row count`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2,3", {}, true); + strictEqual(result.numCols, 2); +}); + +// --- autoCoerce JSON gate only applies to '{' or '[' --- +test(`${variant}: csvCoerceValuesStream does not JSON-parse the bare word null`, async (_t) => { + // "null" is valid JSON but must NOT be parsed (would become the null value); + // it does not start with '{' or '[' so it stays a string. + const out = await streamToArray( + pipejoin([createReadableStream([{ a: "null" }]), csvCoerceValuesStream()]), + ); + strictEqual(out[0].a, "null"); +}); + +test(`${variant}: csvCoerceValuesStream does not JSON-parse a bare quoted-looking word`, async (_t) => { + const out = await streamToArray( + pipejoin([createReadableStream([{ a: "hello" }]), csvCoerceValuesStream()]), + ); + strictEqual(out[0].a, "hello"); +}); + +// --- csvDetectDelimitersStream quote-bracketing scan --- +test(`${variant}: csvDetectDelimitersStream requires a quote to start a field and close at a field end`, async (_t) => { + // Both apostrophes are mid-field; neither opens a field, so the quote char + // must default to ". + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray( + pipejoin([createReadableStream("ab'cd',ef\r\n"), detect]), + ); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream detects a quote that brackets a field after a delimiter`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("x,'a'\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects a quote bracketing the very first field`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("'a',b\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream ignores a quote that opens but never closes at a field end`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("'twas,ok\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +// --- csvDetectHeaderStream: header-only and rest emission --- +test(`${variant}: csvDetectHeaderStream header-only input yields empty header value default and no rows`, async (_t) => { + // Empty input: processBuffer is never called (buffer length 0), so the result + // header stays its initial []. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream(""), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, []); + deepStrictEqual(out, []); +}); + +test(`${variant}: csvDetectHeaderStream header row with trailing newline and no data emits no data rows`, async (_t) => { + // rest is empty (header fills the buffer up to the trailing newline), so the + // rest.length > 0 guard must prevent emitting an empty chunk. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a,b,c\r\n"), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "b", "c"]); + deepStrictEqual(out, []); +}); + +test(`${variant}: csvDetectHeaderStream emits the data rows after the header`, async (_t) => { + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream("a,b\r\n1,2\r\n3,4\r\n"), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "b"]); + deepStrictEqual(out, [ + ["1", "2"], + ["3", "4"], + ]); +}); + +test(`${variant}: csvDetectHeaderStream passes later chunks through unchanged once header detected`, async (_t) => { + // chunkSize 1 so the header is detected on the first chunk; subsequent chunks + // must pass through (headerDetected branch), not be re-parsed as headers. + const hdr = csvDetectHeaderStream({ chunkSize: 1 }); + const out = await streamToArray( + pipejoin([ + createReadableStream(["a,b\r\n", "1,2\r\n", "3,4\r\n"]), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "b"]); + deepStrictEqual(out, [ + ["1", "2"], + ["3", "4"], + ]); +}); + +// --- csvParseStream chunk-type coercion (string vs Buffer) --- +test(`${variant}: csvParseStream parses string chunks without toString coercion`, async (_t) => { + const out = await streamToArray( + pipejoin([createReadableStream("a,b\r\nc,d\r\n"), csvParseStream()]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- numCols established on the quoted-field newline path --- +test(`${variant}: csvQuotedParser numCols from first row ending in a quoted field (escapeIsQuote)`, (_t) => { + // First row ends with "b" then newline (the quoted-field newline branch sets + // numCols); it must be 2 even though the next row has 3 columns. + const result = csvQuotedParser('a,"b"\r\n1,2,3\r\n'); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvQuotedParser numCols from first row ending in a quoted field (custom escape)`, (_t) => { + const result = csvQuotedParser('a,"b"\r\n1,2,3\r\n', { escapeChar: "\\" }); + strictEqual(result.numCols, 2); +}); + +// --- ctx.tail is "" when flushing an unterminated quote --- +test(`${variant}: csvQuotedParser flushing an unterminated quote leaves no tail (escapeIsQuote)`, (_t) => { + const result = csvQuotedParser('"abc', {}, true); + strictEqual(result.tail, ""); + deepStrictEqual(result.rows, [["abc"]]); +}); + +test(`${variant}: csvQuotedParser flushing an unterminated quote leaves no tail (custom escape)`, (_t) => { + const result = csvQuotedParser('x,"abc', { escapeChar: "\\" }, true); + strictEqual(result.tail, ""); + deepStrictEqual(result.rows, [["x", "abc"]]); +}); + +// --- ctx.tail preserves the unterminated row when NOT flushing --- +test(`${variant}: csvQuotedParser not flushing keeps the unterminated quoted row as tail (escapeIsQuote)`, (_t) => { + const result = csvQuotedParser('a,b\r\n"abc', {}, false); + strictEqual(result.tail, '"abc'); + deepStrictEqual(result.rows, [["a", "b"]]); +}); + +test(`${variant}: csvQuotedParser not flushing keeps the unterminated quoted row as tail (custom escape)`, (_t) => { + const result = csvQuotedParser('a,b\r\nx,"abc', { escapeChar: "\\" }, false); + strictEqual(result.tail, 'x,"abc'); + deepStrictEqual(result.rows, [["a", "b"]]); +}); + +// --- trailing delimiter after a quoted field on flush (lastWasDelimiter) --- +test(`${variant}: csvQuotedParser quoted field then trailing delimiter adds an empty field on flush`, (_t) => { + // "a", then a delimiter sets lastWasDelimiter; flushing must append a trailing + // empty field for the dangling delimiter. + const result = csvQuotedParser('"a",', {}, true); + deepStrictEqual(result.rows, [["a", ""]]); +}); + +test(`${variant}: csvParseStream unquoted trailing delimiter adds an empty field on flush`, async (_t) => { + const out = await streamToArray( + pipejoin([createReadableStream("a,b,"), csvParseStream()]), + ); + deepStrictEqual(out, [["a", "b", ""]]); +}); + +// --- csvArrayToObject __proto__ must use defineProperty (not prototype set) --- +test(`${variant}: csvArrayToObject __proto__ column does not pollute the prototype`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([["payload", "v2"]]), + csvArrayToObject({ headers: ["__proto__", "b"] }), + ]), + ); + const obj = out[0]; + // The column is an own enumerable data property, not a prototype assignment. + strictEqual( + Object.getOwnPropertyDescriptor(obj, "__proto__").value, + "payload", + ); + strictEqual(obj.b, "v2"); + deepStrictEqual(Object.keys(obj), ["__proto__", "b"]); +}); + +// --- csvDetectDelimitersStream empty input keeps the initialized value shape --- +test(`${variant}: csvDetectDelimitersStream empty input yields all-undefined detection value`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const out = await streamToArray(pipejoin([createReadableStream(""), detect])); + // Empty input must not emit an empty chunk. + deepStrictEqual(out, []); + deepStrictEqual(detect.result().value, { + delimiterChar: undefined, + newlineChar: undefined, + quoteChar: undefined, + escapeChar: undefined, + }); +}); + +// --- numCols stays first-row count when later quoted-field rows differ --- +test(`${variant}: csvQuotedParser numCols stays first row when later quoted row is shorter (escapeIsQuote)`, (_t) => { + // Row 0 ("a","b") sets numCols via the quoted-field newline path; row 1 ("c") + // also ends via the quoted-field newline path but must NOT reset numCols. + const result = csvQuotedParser('"a","b"\r\n"c"\r\n'); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows, [["a", "b"], ["c"]]); +}); + +test(`${variant}: csvQuotedParser numCols stays first row when later quoted row is shorter (custom escape)`, (_t) => { + const result = csvQuotedParser('"a","b"\r\n"c"\r\n', { escapeChar: "\\" }); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows, [["a", "b"], ["c"]]); +}); + +// --- numCols established in the end-of-input cleanup path --- +test(`${variant}: csvQuotedParser numCols set in cleanup for a lone quoted field on flush`, (_t) => { + // A single quoted field with no newline, flushed: numCols is established in the + // final cleanup block from the field count. + const result = csvQuotedParser('"a"', {}, true); + strictEqual(result.numCols, 1); + deepStrictEqual(result.rows, [["a"]]); +}); + +// --- unescapeCustom: a trailing escape char at end-of-input is kept literally --- +test(`${variant}: csvQuotedParser custom escape unterminated field ending in a lone escape char keeps it literally`, (_t) => { + // Content is "a\" (a then a single backslash) with no following char. The + // trailing escape must be preserved as-is, not consume an out-of-range char. + const result = csvQuotedParser('"a\\', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [["a\\"]]); + ok(result.errors.UnterminatedQuote); +}); + +// --- delimiter/newline tie-break: a delimiter that is a prefix of the newline --- +test(`${variant}: csvQuotedParser delimiter takes precedence when it is a prefix of the newline`, (_t) => { + // With delimiterChar "\r" and newlineChar "\r\n", a "\r\n" begins both a + // delimiter and a newline at the same index. The delimiter wins the tie, so + // "a\r\n" is the row ["a", "\n"] (the "\r" splits, the "\n" is the next field). + const result = csvQuotedParser( + "a\r\n", + { + delimiterChar: "\r", + newlineChar: "\r\n", + }, + true, + ); + deepStrictEqual(result.rows, [["a", "\n"]]); +}); + +test(`${variant}: csvDetectHeaderStream delimiter-prefix-of-newline tie consumes the CR as a delimiter`, async (_t) => { + // findRowEnd must apply the same tie-break: at each "\r\n" the "\r" delimiter + // is taken first, so no pure newline terminates the row — the whole buffer is + // the header and there are no data rows. (If the newline won the tie, the + // header would end at the first "\r".) + const hdr = csvDetectHeaderStream({ + delimiterChar: "\r", + newlineChar: "\r\n", + }); + const out = await streamToArray( + pipejoin([createReadableStream("a\rb\r\n"), hdr]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "b", "\n"]); + deepStrictEqual(out, []); +}); + +// --- csvParseInline: a fully consumed flush leaves an empty tail --- +test(`${variant}: csvQuotedParser flush of complete input leaves an empty tail`, (_t) => { + const result = csvQuotedParser("a,b\r\n1,2", {}, true); + strictEqual(result.tail, ""); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["1", "2"], + ]); +}); + +test(`${variant}: csvQuotedParser non-flushing partial last row is returned as tail`, (_t) => { + const result = csvQuotedParser("a,b\r\n1,2", {}, false); + strictEqual(result.tail, "1,2"); + deepStrictEqual(result.rows, [["a", "b"]]); +}); + +// --- findRowEnd: single-column header (no delimiter present) --- +test(`${variant}: csvDetectHeaderStream single-column header has no delimiter`, async (_t) => { + // There is no delimiter in the buffer, so findRowEnd must still terminate the + // header at the first newline (the no-delimiter branch of the boundary check). + // The header newline sits at index 1, exercising the exact newline position. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a\r\nx\r\ny\r\n"), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, ["a"]); + deepStrictEqual(out, [["x"], ["y"]]); +}); + +// --- findRowEnd: a leading newline makes row 0 an empty header --- +test(`${variant}: csvDetectHeaderStream leading newline yields an empty header row`, async (_t) => { + // The buffer starts with the newline (at index 0), so row 0 is empty and the + // data follows. The row-end at index 0 must be returned, not skipped past a + // later delimiter. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("\r\na,b\r\n"), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, []); + deepStrictEqual(out, [["a", "b"]]); +}); + +// --- findRowEnd: header newline precedes a later delimiter in the data --- +test(`${variant}: csvDetectHeaderStream one-char header ends at its newline before a data delimiter`, async (_t) => { + // The header's newline is at index 1, before any delimiter (the delimiter only + // appears in the data row). findRowEnd must terminate the header at that + // newline rather than skipping ahead to the data delimiter. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a\r\nb,c\r\n"), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, ["a"]); + deepStrictEqual(out, [["b", "c"]]); +}); + +// --- findRowEnd: an unterminated quote in the header means no complete row --- +test(`${variant}: csvDetectHeaderStream unterminated quote in header treats whole buffer as header`, async (_t) => { + // The opening quote is never closed in the buffer, so findRowEnd returns -1 and + // the entire input is parsed as the header with no data rows. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('"unterminated header\r\nmore\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, [ + "unterminated header\r\nmore\r\n", + ]); + deepStrictEqual(out, []); +}); + +// --- findRowEnd: the escape-run lookback must NOT count the opening quote --- +test(`${variant}: csvDetectHeaderStream leading doubled-quote header field keeps embedded newline`, async (_t) => { + // Header field content is ""a\r\nb : the leading "" is an escaped quote, then + // a\r\nb stays inside the field. The escape-run lookback must stop just after + // the opening quote — counting the opening quote would flip the parity and end + // the row at the embedded newline. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('"""a\r\nb",c\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ['"a\r\nb', "c"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +// --- findRowEnd: escaped quote at the very start of a quoted header field --- +test(`${variant}: csvDetectHeaderStream custom escape escaped quote at start of header field keeps embedded newline`, async (_t) => { + // Header field content begins with \" (an escaped quote at the first content + // position); the run-length lookback must include that leading escape so the + // field stays open across the embedded \r\n and the row ends at the real one. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('"\\"a\r\nb",c\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ['"a\r\nb', "c"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +// --- custom escape: an escaped quote at the very start of the field content --- +test(`${variant}: csvQuotedParser custom escape escaped quote at start of content`, (_t) => { + // Content is \"X : the leading \" is an escaped quote (the run-length lookback + // must include the escape char sitting at the first content position), so the + // field is "X and the closing quote is the final one. + const result = csvQuotedParser('"\\"X",b\r\n', { escapeChar: "\\" }); + deepStrictEqual(result.rows, [['"X', "b"]]); +}); + +// --- csvParseInline: a quote only opens a field at a field-start position --- +test(`${variant}: csvQuotedParser treats a mid-field quote as a literal character`, (_t) => { + // The " in a"b is not at a field start, so the field is the unquoted text a"b, + // not the start of a quoted field. + const result = csvQuotedParser('a"b,c\r\nd,e\r\n'); + deepStrictEqual(result.rows, [ + ['a"b', "c"], + ["d", "e"], + ]); +}); + +test(`${variant}: csvQuotedParser mid-field quote literal with custom escape`, (_t) => { + const result = csvQuotedParser('a"b,c\r\nd,e\r\n', { escapeChar: "\\" }); + deepStrictEqual(result.rows, [ + ['a"b', "c"], + ["d", "e"], + ]); +}); + +// --- findRowEnd: delimiter handling opens the following quoted field --- +test(`${variant}: csvDetectHeaderStream delimiter before a quoted field with an embedded newline`, async (_t) => { + // After the delimiter, fieldStart advances so the following quoted field opens; + // its embedded \r\n must stay inside the field and NOT end the header row. If + // the delimiter were not honored, the embedded newline would split the header. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('a,"x\r\ny"\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "x\r\ny"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream multi-char delimiter before a quoted field with an embedded newline`, async (_t) => { + const hdr = csvDetectHeaderStream({ delimiterChar: "::" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('a::"x\r\ny"\r\n1::2\r\n'), + hdr, + csvParseStream({ delimiterChar: "::" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "x\r\ny"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +// --- csvDetectDelimitersStream quote scan: closing quote search starts after opener --- +test(`${variant}: csvDetectDelimitersStream detects a quoted single-char field bracketed by quotes`, async (_t) => { + // The opener is at index 0 and the closer at index 2; the closing-quote search + // must begin after the opener and the run must terminate at the field end. + const detect = csvDetectDelimitersStream(); + await streamToArray(pipejoin([createReadableStream("'a',b,c\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects a quoted field closing exactly at end of text`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await streamToArray(pipejoin([createReadableStream("a,'b'\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream does not detect a single opening quote with no closer`, async (_t) => { + // ' opens at the very start but the next char is a delimiter (a field end), so + // it never brackets a field; the closing-quote search must start AFTER the + // opener, leaving the quote char at the default ". + const detect = csvDetectDelimitersStream(); + await streamToArray(pipejoin([createReadableStream("',b\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream detects an empty quoted first field`, async (_t) => { + // '' is an empty quoted field bracketed at indices 0 and 1; the closer at + // index 1 must be considered (the close-search loop must accept index 0/1). + const detect = csvDetectDelimitersStream(); + await streamToArray(pipejoin([createReadableStream("'',b\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +// --- detect streams: input with no newline is emitted via flush (not lost) --- +test(`${variant}: csvDetectDelimitersStream emits a no-newline input via flush`, async (_t) => { + // No newline means detect() never succeeds in transform; flush must still emit + // the buffered data so nothing is dropped. + const detect = csvDetectDelimitersStream(); + const out = await streamToString( + pipejoin([createReadableStream("a,b,c"), detect]), + ); + strictEqual(out, "a,b,c"); +}); + +test(`${variant}: csvDetectHeaderStream emits a no-newline input as header via flush`, async (_t) => { + // The entire (newline-less) input is the header; flush processes it and emits + // no data rows. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a,b,c"), hdr]), + ); + deepStrictEqual(out, []); + deepStrictEqual(hdr.result().value.header, ["a", "b", "c"]); +}); + +// --- detect streams: emission chunking observed directly (no downstream parser) --- +test(`${variant}: csvDetectDelimitersStream emits each chunk as it detects, not coalesced`, async (_t) => { + // Once the first chunk completes a line, detection fires in transform and the + // chunk is emitted; the next chunk passes straight through as its own chunk. + const detect = csvDetectDelimitersStream(); + const out = await streamToArray( + pipejoin([createReadableStream(["a;b\r\n", "c;d\r\n"]), detect]), + ); + deepStrictEqual(out, ["a;b\r\n", "c;d\r\n"]); + strictEqual(detect.result().value.delimiterChar, ";"); +}); + +test(`${variant}: csvDetectHeaderStream emits the rest in transform, then later chunks separately`, async (_t) => { + // First chunk completes the header row → header detected in transform, the + // data remainder emitted, and the subsequent chunk passes through on its own. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream(["a,b\r\n1,2\r\n", "3,4\r\n"]), hdr]), + ); + deepStrictEqual(out, ["1,2\r\n", "3,4\r\n"]); + deepStrictEqual(hdr.result().value.header, ["a", "b"]); +}); + +test(`${variant}: csvDetectHeaderStream header-only with trailing newline emits nothing (direct)`, async (_t) => { + // rest is empty, so the rest.length > 0 guard must suppress an empty emission. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a,b,c\r\n"), hdr]), + ); + deepStrictEqual(out, []); + deepStrictEqual(hdr.result().value.header, ["a", "b", "c"]); +}); + +test(`${variant}: csvDetectHeaderStream buffers a header split across chunks until the row completes`, async (_t) => { + // The header row is split across two chunks; processing must wait until the + // full first row is present, then emit the data remainder as one chunk. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream(["a,", "b\r\n1,2\r\n"]), hdr]), + ); + deepStrictEqual(out, ["1,2\r\n"]); + deepStrictEqual(hdr.result().value.header, ["a", "b"]); +}); diff --git a/packages/csv/package.json b/packages/csv/package.json index 3bbc4e1..07c2ebc 100644 --- a/packages/csv/package.json +++ b/packages/csv/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/csv", - "version": "0.5.0", + "version": "0.6.1", "description": "CSV parsing and formatting transform streams", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -63,7 +64,7 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0", - "@datastream/object": "0.5.0" + "@datastream/core": "0.6.1", + "@datastream/object": "0.6.1" } } diff --git a/packages/digest/README.md b/packages/digest/README.md index a57f4a1..4f302c7 100644 --- a/packages/digest/README.md +++ b/packages/digest/README.md @@ -1,6 +1,6 @@

<datastream> `digest`

- datastream logo + datastream logo

Hash and checksum streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/digest/index.d.ts b/packages/digest/index.d.ts index 014bca4..f379476 100644 --- a/packages/digest/index.d.ts +++ b/packages/digest/index.d.ts @@ -12,7 +12,11 @@ export type DigestAlgorithm = | "SHA2-512" | "SHA3-256" | "SHA3-384" - | "SHA3-512"; + | "SHA3-512" + // Native aliases accepted on both builds, normalized to the canonical name. + | "SHA256" + | "SHA384" + | "SHA512"; type DigestStreamResult = DatastreamPassThrough & { result: () => StreamResult; diff --git a/packages/digest/index.node.js b/packages/digest/index.node.js index f278a3d..ed83bab 100644 --- a/packages/digest/index.node.js +++ b/packages/digest/index.node.js @@ -3,24 +3,51 @@ import { createHash } from "node:crypto"; import { createPassThroughStream } from "@datastream/core"; +// Canonical datastream algorithm name -> native node crypto name. const algorithmMap = { "SHA2-256": "SHA256", "SHA2-384": "SHA384", "SHA2-512": "SHA512", + "SHA3-256": "SHA3-256", + "SHA3-384": "SHA3-384", + "SHA3-512": "SHA3-512", +}; + +// Native aliases accepted for cross-platform parity, normalized to canonical. +const aliasMap = { + SHA256: "SHA2-256", + SHA384: "SHA2-384", + SHA512: "SHA2-512", +}; + +const normalizeAlgorithm = (algorithm) => { + if (algorithmMap[algorithm]) return algorithm; + if (aliasMap[algorithm]) return aliasMap[algorithm]; + throw new Error(`Unsupported algorithm: ${algorithm}`); }; export const digestStream = ({ algorithm, resultKey }, streamOptions = {}) => { - const hash = createHash(algorithmMap[algorithm] ?? algorithm); + const canonical = normalizeAlgorithm(algorithm); + const hash = createHash(algorithmMap[canonical]); const passThrough = (chunk) => { hash.update(chunk); }; - const stream = createPassThroughStream(passThrough, streamOptions); + let finished = false; + const flush = () => { + finished = true; + }; + const stream = createPassThroughStream(passThrough, flush, streamOptions); let checksum; stream.result = () => { + if (!finished) { + throw new Error( + "digestStream.result() called before the stream finished", + ); + } checksum ??= hash.digest("hex"); return { key: resultKey ?? "digest", - value: `${algorithm}:${checksum}`, + value: `${canonical}:${checksum}`, }; }; return stream; diff --git a/packages/digest/index.test.js b/packages/digest/index.test.js index 22db559..c6e746f 100644 --- a/packages/digest/index.test.js +++ b/packages/digest/index.test.js @@ -1,10 +1,14 @@ -import { strictEqual } from "node:assert"; +import { rejects, strictEqual, throws } from "node:assert"; import test from "node:test"; import { createReadableStream, pipeline } from "@datastream/core"; import digestDefault, { digestStream } from "@datastream/digest"; +const webDigest = await import( + `file://${new URL("./index.web.js", import.meta.url).pathname}` +); + let variant = "unknown"; for (const execArgv of process.execArgv) { const flag = "--conditions="; @@ -52,14 +56,112 @@ test(`${variant}: digestStream should calculate digest from chunks`, async (_t) ); }); -test(`${variant}: digestStream should use native algorithm name`, async (_t) => { +test(`${variant}: digestStream should accept native algorithm name and normalize prefix`, async (_t) => { + const streams = [ + createReadableStream("test"), + await digestStream({ algorithm: "SHA256" }), + ]; + const result = await pipeline(streams); + + // Native alias "SHA256" must normalize to the canonical "SHA2-256" label so + // the emitted digest carries the same prefix cross-platform. + strictEqual(result.digest.startsWith("SHA2-256:"), true); +}); + +test(`${variant}: digestStream should accept native SHA384 alias and normalize prefix`, async (_t) => { + const streams = [ + createReadableStream("test"), + await digestStream({ algorithm: "SHA384" }), + ]; + const result = await pipeline(streams); + // Native alias "SHA384" must normalize to the canonical "SHA2-384" label. + strictEqual(result.digest.startsWith("SHA2-384:"), true); +}); + +test(`${variant}: digestStream should accept native SHA512 alias and normalize prefix`, async (_t) => { + const streams = [ + createReadableStream("test"), + await digestStream({ algorithm: "SHA512" }), + ]; + const result = await pipeline(streams); + // Native alias "SHA512" must normalize to the canonical "SHA2-512" label. + strictEqual(result.digest.startsWith("SHA2-512:"), true); +}); + +test(`${variant}: digestStream native alias matches canonical name (node)`, async (_t) => { + const aliasStreams = [ + createReadableStream("test"), + await digestStream({ algorithm: "SHA256" }), + ]; + const canonicalStreams = [ + createReadableStream("test"), + await digestStream({ algorithm: "SHA2-256" }), + ]; + const aliasResult = await pipeline(aliasStreams); + const canonicalResult = await pipeline(canonicalStreams); + strictEqual(aliasResult.digest, canonicalResult.digest); +}); + +// *** node/web parity: native algorithm names *** // +test(`${variant}: web digestStream should accept native algorithm name (parity)`, async (_t) => { const streams = [ + createReadableStream("test"), + await webDigest.digestStream({ algorithm: "SHA256" }), + ]; + const result = await pipeline(streams); + // Web must accept the native alias (not throw) and emit the canonical prefix. + strictEqual(result.digest.startsWith("SHA2-256:"), true); +}); + +test(`${variant}: web digestStream native alias matches node digest`, async (_t) => { + const webStreams = [ + createReadableStream("test"), + await webDigest.digestStream({ algorithm: "SHA256" }), + ]; + const nodeStreams = [ createReadableStream("test"), await digestStream({ algorithm: "SHA256" }), ]; + const webResult = await pipeline(webStreams); + const nodeResult = await pipeline(nodeStreams); + strictEqual(webResult.digest, nodeResult.digest); +}); + +// *** unsupported algorithm rejected consistently *** // +test(`${variant}: digestStream should reject unsupported algorithm (node)`, async (_t) => { + throws(() => digestStream({ algorithm: "MD5" }), { + message: "Unsupported algorithm: MD5", + }); +}); + +test(`${variant}: web digestStream should reject unsupported algorithm`, (_t) => { + // Unified contract: the web factory is now synchronous (matching node), so it + // throws synchronously rather than rejecting a Promise. + throws(() => webDigest.digestStream({ algorithm: "MD5" }), { + message: "Unsupported algorithm: MD5", + }); +}); + +// *** result() premature finalization guard *** // +test(`${variant}: digestStream.result() throws before stream finishes (node)`, async (_t) => { + const stream = await digestStream({ algorithm: "SHA2-256" }); + throws(() => stream.result(), { + message: "digestStream.result() called before the stream finished", + }); + // After consuming the stream, result() works. + const streams = [createReadableStream("test"), stream]; const result = await pipeline(streams); + strictEqual(result.digest.startsWith("SHA2-256:"), true); +}); - strictEqual(result.digest.startsWith("SHA256:"), true); +test(`${variant}: web digestStream.result() throws before stream finishes`, async (_t) => { + const stream = await webDigest.digestStream({ algorithm: "SHA2-256" }); + throws(() => stream.result(), { + message: "digestStream.result() called before the stream finished", + }); + const streams = [createReadableStream("test"), stream]; + const result = await pipeline(streams); + strictEqual(result.digest.startsWith("SHA2-256:"), true); }); test(`${variant}: digestStream should use custom resultKey`, async (_t) => { @@ -120,7 +222,99 @@ test(`${variant}: digestStream should calculate SHA3-512`, async (_t) => { strictEqual(result.digest.startsWith("SHA3-512:"), true); }); +// *** web build: all algorithm variants (exercise web algorithm map) *** // +for (const algorithm of [ + "SHA2-256", + "SHA2-384", + "SHA2-512", + "SHA3-256", + "SHA3-384", + "SHA3-512", +]) { + test(`${variant}: web digestStream should calculate ${algorithm}`, async (_t) => { + const streams = [ + createReadableStream("test"), + await webDigest.digestStream({ algorithm }), + ]; + const result = await pipeline(streams); + strictEqual(result.digest.startsWith(`${algorithm}:`), true); + }); +} + +// *** unified sync factory contract: both builds return a stream synchronously *** // +test(`${variant}: digestStream returns a stream synchronously (node)`, (_t) => { + const stream = digestStream({ algorithm: "SHA2-256" }); + // Must be a stream, not a Promise, so callers do not need to await. + strictEqual(typeof stream?.then, "undefined"); + strictEqual(typeof stream?.result, "function"); +}); + +test(`${variant}: web digestStream returns a stream synchronously (parity)`, (_t) => { + const stream = webDigest.digestStream({ algorithm: "SHA2-256" }); + // Web must match node: synchronous return, no Promise wrapper. + strictEqual(typeof stream?.then, "undefined"); + strictEqual(typeof stream?.result, "function"); +}); + +test(`${variant}: web digestStream works without await (sync contract)`, async (_t) => { + const streams = [ + createReadableStream("1,2,3,4"), + webDigest.digestStream({ algorithm: "SHA2-256" }), + ]; + const result = await pipeline(streams); + strictEqual( + result.digest, + "SHA2-256:37db36876b9ccaaa88394679f019c3435af9320dea117e867003840317870e25", + ); +}); + +// *** full-value cross-build parity for every algorithm *** // +for (const algorithm of [ + "SHA2-256", + "SHA2-384", + "SHA2-512", + "SHA3-256", + "SHA3-384", + "SHA3-512", +]) { + test(`${variant}: ${algorithm} node and web digests are identical`, async (_t) => { + const nodeResult = await pipeline([ + createReadableStream("The quick brown fox"), + digestStream({ algorithm }), + ]); + const webResult = await pipeline([ + createReadableStream("The quick brown fox"), + webDigest.digestStream({ algorithm }), + ]); + strictEqual(nodeResult.digest, webResult.digest); + strictEqual(nodeResult.digest.startsWith(`${algorithm}:`), true); + }); +} + +// *** error propagation: a non-hashable chunk must reject the pipeline *** // +test(`${variant}: digestStream rejects on a non-hashable chunk (node)`, async (_t) => { + await rejects( + pipeline([ + createReadableStream([42], { objectMode: true }), + digestStream({ algorithm: "SHA2-256" }), + ]), + ); +}); + +test(`${variant}: web digestStream rejects on a non-hashable chunk`, async (_t) => { + await rejects( + pipeline([ + createReadableStream([42], { objectMode: true }), + webDigest.digestStream({ algorithm: "SHA2-256" }), + ]), + ); +}); + // *** default export *** // test(`${variant}: default export should be digestStream`, (_t) => { strictEqual(digestDefault, digestStream); }); + +test(`${variant}: web default export should be digestStream`, (_t) => { + strictEqual(webDigest.default, webDigest.digestStream); +}); diff --git a/packages/digest/index.web.js b/packages/digest/index.web.js index 1d2dc51..2e7dc9d 100644 --- a/packages/digest/index.web.js +++ b/packages/digest/index.web.js @@ -8,6 +8,7 @@ import { createSHA512, } from "hash-wasm"; +// Canonical datastream algorithm name -> hash-wasm constructor. const algorithms = { "SHA2-256": createSHA256, "SHA2-384": createSHA384, @@ -17,24 +18,48 @@ const algorithms = { "SHA3-512": () => createSHA3(512), }; -export const digestStream = async ( - { algorithm, resultKey }, - streamOptions = {}, -) => { - if (!algorithms[algorithm]) { - throw new Error(`Unsupported algorithm: ${algorithm}`); - } - const hash = await algorithms[algorithm](); - const passThrough = (chunk) => { +// Native aliases accepted for cross-platform parity, normalized to canonical. +const aliasMap = { + SHA256: "SHA2-256", + SHA384: "SHA2-384", + SHA512: "SHA2-512", +}; + +const normalizeAlgorithm = (algorithm) => { + if (algorithms[algorithm]) return algorithm; + if (aliasMap[algorithm]) return aliasMap[algorithm]; + throw new Error(`Unsupported algorithm: ${algorithm}`); +}; + +export const digestStream = ({ algorithm, resultKey }, streamOptions = {}) => { + const canonical = normalizeAlgorithm(algorithm); + // hash-wasm constructors are async; kick off WASM init eagerly but resolve it + // lazily inside passThrough/flush so the factory matches the Node build and + // returns the stream synchronously (no Promise for callers to await). + const hashPromise = algorithms[canonical](); + let hash; + const passThrough = async (chunk) => { + hash ??= await hashPromise; hash.update(chunk); }; - const stream = createPassThroughStream(passThrough, streamOptions); + let finished = false; + const flush = async () => { + // Ensure the hash is initialized even when the stream carried no chunks. + hash ??= await hashPromise; + finished = true; + }; + const stream = createPassThroughStream(passThrough, flush, streamOptions); let checksum; stream.result = () => { + if (!finished) { + throw new Error( + "digestStream.result() called before the stream finished", + ); + } checksum ??= hash.digest(); return { key: resultKey ?? "digest", - value: `${algorithm}:${checksum}`, + value: `${canonical}:${checksum}`, }; }; return stream; diff --git a/packages/digest/package.json b/packages/digest/package.json index 70c5df8..4636989 100644 --- a/packages/digest/package.json +++ b/packages/digest/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/digest", - "version": "0.5.0", + "version": "0.6.1", "description": "Cryptographic hash digest pass-through streams", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,7 +61,7 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0", + "@datastream/core": "0.6.1", "hash-wasm": "4.12.0" } } diff --git a/packages/duckdb/README.md b/packages/duckdb/README.md new file mode 100644 index 0000000..9895364 --- /dev/null +++ b/packages/duckdb/README.md @@ -0,0 +1,52 @@ +
+

<datastream> `duckdb`

+ datastream logo +

DuckDB writable streams (copy-from).

+

+ GitHub Actions unit test status + GitHub Actions dast test status + GitHub Actions perf test status + GitHub Actions SAST test status + GitHub Actions lint test status +
+ npm version + npm install size + + npm weekly downloads + + npm provenance +
+ Open Source Security Foundation (OpenSSF) Scorecard + SLSA 3 + + Checked with Biome + Conventional Commits + + code coverage +

+

You can read the documentation at: https://datastream.js.org

+
+ + +## Install + +To install datastream you can use NPM: + +```bash +npm install --save @datastream/duckdb +``` + + +## Documentation and examples + +For documentation and examples, refer to the main [datastream monorepo on GitHub](https://github.com/willfarrell/datastream) or [datastream official website](https://datastream.js.org). + + +## Contributing + +Everyone is very welcome to contribute to this repository. Feel free to [raise issues](https://github.com/willfarrell/datastream/issues) or to [submit Pull Requests](https://github.com/willfarrell/datastream/pulls). + + +## License + +Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell), and [datastream contributors](https://github.com/willfarrell/datastream/graphs/contributors). \ No newline at end of file diff --git a/packages/duckdb/index.d.ts b/packages/duckdb/index.d.ts new file mode 100644 index 0000000..6834cd3 --- /dev/null +++ b/packages/duckdb/index.d.ts @@ -0,0 +1,27 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import type { DatastreamWritable, StreamOptions } from "@datastream/core"; +import type { RecordBatch, Schema } from "apache-arrow"; + +export function duckdbConnect( + path?: string, + options?: Record, +): Promise; + +export function duckdbAppenderStream( + options: { + db: unknown; + table: string; + schema?: Schema | (() => Schema); + }, + streamOptions?: StreamOptions, +): Promise>>; + +export function duckdbArrowInsertStream( + options: { + db: unknown; + table: string; + schema?: Schema | (() => Schema); + }, + streamOptions?: StreamOptions, +): Promise>; diff --git a/packages/duckdb/index.fuzz.js b/packages/duckdb/index.fuzz.js new file mode 100644 index 0000000..947769e --- /dev/null +++ b/packages/duckdb/index.fuzz.js @@ -0,0 +1,176 @@ +import test from "node:test"; +import { arrowBatchFromObjectStream } from "@datastream/arrow"; +import { createReadableStream, pipeline } from "@datastream/core"; +import { + duckdbAppenderStream, + duckdbArrowInsertStream, + duckdbConnect, +} from "@datastream/duckdb"; +import { Field, Int32, Schema, Utf8 } from "apache-arrow"; +import fc from "fast-check"; + +const catchError = (input, e) => { + const expectedErrors = [ + // Invalid table name (non-string or empty string) + "duckdb: identifier must be a non-empty string", + // DuckDB type coercion failures (e.g. out-of-range float → INTEGER) + "Failed to cast value:", + // Cannot store objects/arrays/symbols/functions into typed columns + "Cannot create values of type ANY. Specify a specific type.", + // Column count mismatch between batch and table + "column count mismatch", + ]; + for (const prefix of expectedErrors) { + if (e.message?.includes(prefix) || e.message === prefix) { + return; + } + } + console.error(input, e); + throw e; +}; + +// Fixed in-memory schema for the shared users table used across fuzz iterations. +const arrowSchema = new Schema([ + new Field("id", new Int32(), true), + new Field("name", new Utf8(), true), +]); + +// *** duckdbAppenderStream — fuzz random object rows *** // +test("fuzz duckdbAppenderStream w/ random object rows", async () => { + const db = await duckdbConnect(); + await db.run("CREATE TABLE fuzz_appender (id INTEGER, name VARCHAR)"); + + await fc.assert( + fc.asyncProperty( + // Generate arrays of objects with arbitrary id/name values (including + // edge cases like null, undefined, wrong types, booleans, etc.) + fc.array( + fc.record({ + id: fc.oneof( + fc.integer(), + fc.float(), + fc.string(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + ), + name: fc.oneof( + fc.string(), + fc.integer(), + fc.constant(null), + fc.constant(undefined), + ), + }), + { minLength: 1, maxLength: 20 }, + ), + async (input) => { + try { + await pipeline([ + createReadableStream(input), + await duckdbAppenderStream({ db, table: "fuzz_appender" }), + ]); + // Reset table for next iteration so row counts don't grow unbounded. + await db.run("DELETE FROM fuzz_appender"); + } catch (e) { + catchError(input, e); + } + }, + ), + { + numRuns: 200, + verbose: 2, + examples: [], + }, + ); + + db.closeSync(); +}); + +// *** duckdbAppenderStream — fuzz random table names and row shapes *** // +test("fuzz duckdbAppenderStream w/ random table name", async () => { + await fc.assert( + fc.asyncProperty( + // Table names — many will be invalid (non-string, empty, etc.) + fc.oneof( + fc.string({ minLength: 0, maxLength: 50 }), + fc.integer(), + fc.constant(null), + fc.constant(undefined), + ), + fc.array( + fc.record({ + id: fc.integer({ min: 0, max: 9_999 }), + name: fc.string({ minLength: 0, maxLength: 20 }), + }), + { minLength: 1, maxLength: 5 }, + ), + async (table, input) => { + const db = await duckdbConnect(); + // Only create the table when the name is a valid non-empty string so + // we exercise both valid and invalid-name paths cleanly. + if (typeof table === "string" && table.length > 0) { + try { + await db.run( + `CREATE TABLE "${table.replaceAll('"', '""')}" (id INTEGER, name VARCHAR)`, + ); + } catch (_) { + // Some random strings produce invalid SQL even after quoting; + // skip table creation and let the appender surface the error. + } + } + try { + await pipeline([ + createReadableStream(input), + await duckdbAppenderStream({ db, table }), + ]); + } catch (e) { + catchError({ table, input }, e); + } + db.closeSync(); + }, + ), + { + numRuns: 200, + verbose: 2, + examples: [], + }, + ); +}); + +// *** duckdbArrowInsertStream — fuzz random Arrow record batches *** // +test("fuzz duckdbArrowInsertStream w/ random object rows via Arrow batches", async () => { + const db = await duckdbConnect(); + await db.run("CREATE TABLE fuzz_arrow (id INTEGER, name VARCHAR)"); + + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + id: fc.oneof(fc.integer({ min: 0, max: 9_999 }), fc.constant(null)), + name: fc.oneof(fc.string({ maxLength: 30 }), fc.constant(null)), + }), + { minLength: 1, maxLength: 50 }, + ), + fc.integer({ min: 1, max: 100 }), + async (input, batchSize) => { + try { + await pipeline([ + createReadableStream(input), + arrowBatchFromObjectStream({ schema: arrowSchema, batchSize }), + await duckdbArrowInsertStream({ db, table: "fuzz_arrow" }), + ]); + await db.run("DELETE FROM fuzz_arrow"); + } catch (e) { + catchError({ input, batchSize }, e); + } + }, + ), + { + numRuns: 200, + verbose: 2, + examples: [], + }, + ); + + db.closeSync(); +}); diff --git a/packages/duckdb/index.node.js b/packages/duckdb/index.node.js new file mode 100644 index 0000000..3e5df8f --- /dev/null +++ b/packages/duckdb/index.node.js @@ -0,0 +1,271 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { createWritableStream, resolveLazy } from "@datastream/core"; +import { DuckDBInstance } from "@duckdb/node-api"; + +export const duckdbConnect = async (path = ":memory:", options) => { + // An empty path is a caller mistake (node-api would silently open an in-memory + // database, masking the bug). Reject it so the ":memory:" sentinel must be used + // explicitly for an in-memory database. + if (typeof path !== "string" || path.length === 0) { + throw new TypeError( + 'duckdb: path must be a non-empty string (use ":memory:" for in-memory)', + ); + } + const instance = await DuckDBInstance.create(path, options); + return await instance.connect(); +}; + +// DuckDB has no parameter binding for identifiers, so table/column names are +// interpolated into SQL. Double the embedded quotes and require a non-empty +// string so a crafted name can't break out of the quoted identifier. +const quoteIdent = (name) => { + if (typeof name !== "string" || name.length === 0) { + throw new TypeError("duckdb: identifier must be a non-empty string"); + } + return `"${name.replaceAll('"', '""')}"`; +}; + +// A failed existence probe should only be read as "table does not exist" when +// the error is a missing-table/catalog error. Any other failure (lock, +// permission, column read error, ...) must propagate so it is not masked by a +// confusing downstream "table already exists" from CREATE TABLE. +const isMissingTableError = (error) => { + const message = String(error?.message ?? error).toLowerCase(); + return ( + message.includes("does not exist") || + message.includes("not found") || + message.includes("catalog error") + ); +}; + +const tableExists = async (db, table) => { + try { + await db.run(`SELECT 1 FROM ${quoteIdent(table)} LIMIT 0`); + return true; + } catch (error) { + if (isMissingTableError(error)) return false; + throw error; + } +}; + +const fetchColumnNames = async (db, table) => { + const reader = await db.runAndReadAll( + `SELECT * FROM ${quoteIdent(table)} LIMIT 0`, + ); + return reader.columnNames(); +}; + +const arrowTypeToDuckDBSQL = (type) => { + const name = type?.constructor?.name; + switch (name) { + case "Bool": + return "BOOLEAN"; + case "Int8": + return "TINYINT"; + case "Int16": + return "SMALLINT"; + case "Int32": + return "INTEGER"; + case "Int64": + return "BIGINT"; + case "Uint8": + return "UTINYINT"; + case "Uint16": + return "USMALLINT"; + case "Uint32": + return "UINTEGER"; + case "Uint64": + return "UBIGINT"; + case "Float32": + return "REAL"; + case "Float64": + return "DOUBLE"; + case "Date_": + return "DATE"; + case "TimestampSecond": + return "TIMESTAMP_S"; + case "TimestampMillisecond": + return "TIMESTAMP_MS"; + case "TimestampMicrosecond": + return "TIMESTAMP"; + case "TimestampNanosecond": + return "TIMESTAMP_NS"; + default: + return "VARCHAR"; + } +}; + +const createTableFromArrowSchema = async (db, table, schema) => { + const cols = schema.fields + .map((f) => `${quoteIdent(f.name)} ${arrowTypeToDuckDBSQL(f.type)}`) + .join(", "); + await db.run(`CREATE TABLE ${quoteIdent(table)} (${cols})`); +}; + +const ensureTableAndColumns = async (db, table, schema) => { + if (schema && !(await tableExists(db, table))) { + await createTableFromArrowSchema(db, table, schema); + } + // Always derive the column order from the table's PHYSICAL layout. The + // appender appends positionally into physical columns, so trusting the + // provided schema.fields order would misalign (or hard-fail with a cast + // error) whenever the schema order differs from the physical column order. + return fetchColumnNames(db, table); +}; + +const appendCell = (appender, value) => { + if (value === null || value === undefined) { + appender.appendNull(); + } else { + appender.appendValue(value); + } +}; + +// Release a native appender handle exactly once. createWritableStream only runs +// final() on normal completion, so a thrown write() (or an aborted pipeline) +// would otherwise leak the underlying native appender. +const closeAppenderOnce = (state) => { + if (!state.appender || state.closed) return; + state.closed = true; + try { + state.appender.closeSync(); + } catch (_error) { + // best-effort: the handle is being torn down on an error path. + } +}; + +// createWritableStream (core) only wires write/final, so error/abort teardown +// never releases the native appender. Attach cleanup to the Writable's lifecycle +// (error/close) and to streamOptions.signal so an upstream error or an abort +// still closes the handle. closeAppenderOnce is idempotent, so the normal +// final()-then-close path stays a no-op here. +const wireAppenderCleanup = (stream, state, streamOptions) => { + const cleanup = () => closeAppenderOnce(state); + stream.once("error", cleanup); + stream.once("close", cleanup); + // streamOptions always defaults to {} at the public entry points, so it is + // never nullish here. + const signal = streamOptions.signal; + if (signal) { + // The appender is created lazily (during the first write), so it never + // exists yet at wiring time; an already-aborted signal is therefore handled + // by the stream's own abort teardown (which fires "error"/"close" above). + // For a not-yet-aborted signal, release the handle when it aborts and stop + // listening once the stream closes normally. + signal.addEventListener("abort", cleanup, { once: true }); + stream.once("close", () => signal.removeEventListener("abort", cleanup)); + } + return stream; +}; + +export const duckdbAppenderStream = async ( + { db, table, schema }, + streamOptions = {}, +) => { + // state.appender starts undefined (set lazily in init) and state.closed starts + // falsy; both are read with truthiness checks, so an empty object suffices. + const state = {}; + let columnNames; + + const init = async () => { + const resolvedSchema = resolveLazy(schema); + columnNames = await ensureTableAndColumns(db, table, resolvedSchema); + state.appender = await db.createAppender(table); + }; + + const write = async (row) => { + if (!state.appender) await init(); + try { + const isArray = Array.isArray(row); + for (let i = 0, l = columnNames.length; i < l; i++) { + const v = isArray ? row[i] : row[columnNames[i]]; + appendCell(state.appender, v); + } + state.appender.endRow(); + } catch (error) { + closeAppenderOnce(state); + throw error; + } + }; + const final = async () => { + if (state.appender && !state.closed) { + state.appender.flushSync(); + closeAppenderOnce(state); + } + }; + + return wireAppenderCleanup( + createWritableStream(write, final, streamOptions), + state, + streamOptions, + ); +}; + +export const duckdbArrowInsertStream = async ( + { db, table, schema }, + streamOptions = {}, +) => { + // See duckdbAppenderStream: appender/closed are only read for truthiness. + const state = {}; + let columnNames; + + const init = async () => { + const resolvedSchema = resolveLazy(schema); + columnNames = await ensureTableAndColumns(db, table, resolvedSchema); + state.appender = await db.createAppender(table); + }; + + const write = async (batch) => { + if (!state.appender) await init(); + try { + const colCount = columnNames.length; + // Validate the batch's column count against the target table so a + // mismatch surfaces a clear schema error instead of a null-deref + // (too few columns) or silent column drop (too many columns). + const batchColCount = batch.schema?.fields?.length ?? batch.numCols; + if (batchColCount !== colCount) { + throw new Error( + `duckdb: record batch column count (${batchColCount}) does not match table "${table}" column count (${colCount})`, + ); + } + const cols = []; + for (let i = 0; i < colCount; i++) { + const col = batch.getChildAt(i); + if (col === null || col === undefined) { + throw new Error( + `duckdb: record batch is missing column ${i} for table "${table}"`, + ); + } + cols.push(col); + } + const rowCount = batch.numRows; + for (let r = 0; r < rowCount; r++) { + for (let i = 0; i < colCount; i++) + appendCell(state.appender, cols[i].get(r)); + state.appender.endRow(); + } + } catch (error) { + closeAppenderOnce(state); + throw error; + } + }; + const final = async () => { + if (state.appender && !state.closed) { + state.appender.flushSync(); + closeAppenderOnce(state); + } + }; + + return wireAppenderCleanup( + createWritableStream(write, final, streamOptions), + state, + streamOptions, + ); +}; + +export default { + connect: duckdbConnect, + appenderStream: duckdbAppenderStream, + arrowInsertStream: duckdbArrowInsertStream, +}; diff --git a/packages/duckdb/index.perf.js b/packages/duckdb/index.perf.js new file mode 100644 index 0000000..350444e --- /dev/null +++ b/packages/duckdb/index.perf.js @@ -0,0 +1,101 @@ +import test from "node:test"; +import { + arrowBatchFromObjectStream, + arrowDetectSchemaStream, +} from "@datastream/arrow"; +import { createReadableStream, pipeline } from "@datastream/core"; +import { + duckdbAppenderStream, + duckdbArrowInsertStream, + duckdbConnect, +} from "@datastream/duckdb"; +import { Field, Int32, Schema, Utf8 } from "apache-arrow"; +import { Bench } from "tinybench"; + +// -- Config -- + +const time = Number(process.env.BENCH_TIME ?? 5_000); +const N = 10_000; + +// -- Data generators -- + +const rows = Array.from({ length: N }, (_, i) => ({ + id: i, + name: `user_${i}`, +})); + +const arrowSchema = new Schema([ + new Field("id", new Int32(), true), + new Field("name", new Utf8(), true), +]); + +// -- Tests -- + +test("perf: duckdbAppenderStream insert throughput", async () => { + const bench = new Bench({ name: "duckdbAppenderStream", time }); + const db = await duckdbConnect(); + let tableIdx = 0; + + bench.add(`${N} object rows`, async () => { + const table = `bench_appender_${tableIdx++}`; + await db.run(`CREATE TABLE ${table} (id INTEGER, name VARCHAR)`); + await pipeline([ + createReadableStream(rows), + await duckdbAppenderStream({ db, table }), + ]); + }); + + await bench.run(); + db.closeSync(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: duckdbArrowInsertStream insert throughput", async () => { + const bench = new Bench({ name: "duckdbArrowInsertStream", time }); + const db = await duckdbConnect(); + let tableIdx = 0; + + bench.add(`${N} rows via Arrow batches (batchSize=1000)`, async () => { + const table = `bench_arrow_${tableIdx++}`; + await db.run(`CREATE TABLE ${table} (id INTEGER, name VARCHAR)`); + await pipeline([ + createReadableStream(rows), + arrowBatchFromObjectStream({ schema: arrowSchema, batchSize: 1_000 }), + await duckdbArrowInsertStream({ db, table }), + ]); + }); + + await bench.run(); + db.closeSync(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: duckdbAppenderStream with schema auto-create table", async () => { + const bench = new Bench({ + name: "duckdbAppenderStream (schema + auto-create)", + time, + }); + const db = await duckdbConnect(); + let tableIdx = 0; + + bench.add(`${N} rows, schema provided`, async () => { + const table = `bench_appender_schema_${tableIdx++}`; + const detect = arrowDetectSchemaStream({ sampleSize: 10 }); + await pipeline([ + createReadableStream(rows), + detect, + await duckdbAppenderStream({ + db, + table, + schema: () => detect.result().value.schema, + }), + ]); + }); + + await bench.run(); + db.closeSync(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); diff --git a/packages/duckdb/index.test.js b/packages/duckdb/index.test.js new file mode 100644 index 0000000..cc1fa23 --- /dev/null +++ b/packages/duckdb/index.test.js @@ -0,0 +1,2016 @@ +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import test from "node:test"; + +import { + arrowBatchFromObjectStream, + arrowDetectSchemaStream, +} from "@datastream/arrow"; +import { createReadableStream, pipeline } from "@datastream/core"; +import { + duckdbAppenderStream, + duckdbArrowInsertStream, + duckdbConnect, +} from "@datastream/duckdb"; +import { + Bool, + Date_, + Field, + Float32, + Float64, + Int8, + Int16, + Int32, + Int64, + Schema, + TimestampMicrosecond, + TimestampMillisecond, + TimestampNanosecond, + TimestampSecond, + Uint8, + Uint16, + Uint32, + Uint64, + Utf8, +} from "apache-arrow"; + +let variant = "unknown"; +for (const execArgv of process.execArgv) { + const flag = "--conditions="; + if (execArgv.includes(flag)) { + variant = execArgv.replace(flag, ""); + } +} + +// Tests run unconditionally — Node satisfies the "node" export condition +// under both --conditions=node and --conditions=webstream, so the @duckdb/node-api +// impl loads either way. + +const usersArrowSchema = new Schema([ + new Field("id", new Int32(), true), + new Field("name", new Utf8(), true), +]); + +const setupTable = async () => { + const db = await duckdbConnect(); + await db.run("CREATE TABLE users (id INTEGER, name VARCHAR)"); + return db; +}; + +const selectAll = async (db) => { + const reader = await db.runAndReadAll( + "SELECT id, name FROM users ORDER BY id", + ); + return reader.getRowsJS(); +}; + +test(`${variant}: duckdbAppenderStream should insert object rows and pull schema from DESCRIBE`, async () => { + const db = await setupTable(); + await pipeline([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]), + await duckdbAppenderStream({ db, table: "users" }), + ]); + const rows = await selectAll(db); + deepStrictEqual(rows, [ + [1, "alice"], + [2, "bob"], + ]); + db.closeSync(); +}); + +test(`${variant}: duckdbAppenderStream should insert array rows`, async () => { + const db = await setupTable(); + await pipeline([ + createReadableStream([ + [1, "alice"], + [2, "bob"], + ]), + await duckdbAppenderStream({ db, table: "users" }), + ]); + const rows = await selectAll(db); + strictEqual(rows.length, 2); + strictEqual(rows[0][1], "alice"); + db.closeSync(); +}); + +test(`${variant}: duckdbAppenderStream should handle nulls`, async () => { + const db = await setupTable(); + await pipeline([ + createReadableStream([{ id: 1, name: null }]), + await duckdbAppenderStream({ db, table: "users" }), + ]); + const rows = await selectAll(db); + deepStrictEqual(rows, [[1, null]]); + db.closeSync(); +}); + +test(`${variant}: duckdbArrowInsertStream should insert Arrow record batches`, async () => { + const db = await setupTable(); + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + await pipeline([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + { id: 3, name: "carol" }, + ]), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + batchSize: 2, + }), + await duckdbArrowInsertStream({ db, table: "users" }), + ]); + const rows = await selectAll(db); + deepStrictEqual(rows, [ + [1, "alice"], + [2, "bob"], + [3, "carol"], + ]); + db.closeSync(); +}); + +test(`${variant}: duckdbAppenderStream should CREATE TABLE when given a schema and table is missing`, async () => { + const db = await duckdbConnect(); + const detect = arrowDetectSchemaStream({ sampleSize: 2 }); + await pipeline([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]), + detect, + await duckdbAppenderStream({ + db, + table: "users", + schema: () => detect.result().value.schema, + }), + ]); + const rows = await selectAll(db); + deepStrictEqual(rows, [ + [1, "alice"], + [2, "bob"], + ]); + db.closeSync(); +}); + +test(`${variant}: duckdbAppenderStream escapes identifiers / neutralizes SQL injection in table name`, async () => { + const db = await duckdbConnect(); + await db.run("CREATE TABLE secret (id INTEGER)"); + await db.run("INSERT INTO secret VALUES (1)"); + // A quote-laden name must be treated as a single (weird) identifier, not as + // a statement boundary that drops `secret`. + const table = `evil"; DROP TABLE secret; --`; + const detect = arrowDetectSchemaStream({ sampleSize: 1 }); + await pipeline([ + createReadableStream([{ id: 1, name: "alice" }]), + detect, + await duckdbAppenderStream({ + db, + table, + schema: () => detect.result().value.schema, + }), + ]); + // `secret` survived because the malicious name was escaped into one ident. + const reader = await db.runAndReadAll("SELECT id FROM secret"); + deepStrictEqual(reader.getRowsJS(), [[1]]); + db.closeSync(); +}); + +test(`${variant}: duckdbAppenderStream closes the appender when a write fails (no native handle leak)`, async () => { + let closed = false; + const appender = { + appendValue: (v) => { + if (v === "poison") throw new Error("bad cell"); + }, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id", "name"] }), + createAppender: async () => appender, + }; + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1, name: "poison" }]), + await duckdbAppenderStream({ db, table: "users" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "the write error must propagate"); + ok(caught.message.includes("bad cell")); + // The native appender handle must be released even though final() never ran. + strictEqual(closed, true); +}); + +test(`${variant}: duckdbArrowInsertStream closes the appender when a write fails (no native handle leak)`, async () => { + let closed = false; + const appender = { + appendValue: () => { + throw new Error("bad batch cell"); + }, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id", "name"] }), + createAppender: async () => appender, + }; + const batchStream = arrowBatchFromObjectStream({ + schema: usersArrowSchema, + batchSize: 10, + }); + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1, name: "alice" }]), + batchStream, + await duckdbArrowInsertStream({ db, table: "users" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "the write error must propagate"); + strictEqual(closed, true); +}); + +test(`${variant}: duckdbAppenderStream rejects a non-string table name`, async () => { + const db = await duckdbConnect(); + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: 42 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("identifier must be a non-empty string")); + } + db.closeSync(); +}); + +// Provided schema field order differs from the physical table column order. +// The appender appends positionally into the table's PHYSICAL columns, so the +// column order MUST come from the physical table, never from schema.fields. +const reversedUsersSchema = new Schema([ + new Field("name", new Utf8(), true), + new Field("id", new Int32(), true), +]); + +test(`${variant}: duckdbAppenderStream maps object rows by physical column order when provided schema order differs`, async () => { + const db = await setupTable(); // physical order: id INTEGER, name VARCHAR + await pipeline([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]), + await duckdbAppenderStream({ + db, + table: "users", + schema: reversedUsersSchema, + }), + ]); + const rows = await selectAll(db); + deepStrictEqual(rows, [ + [1, "alice"], + [2, "bob"], + ]); + db.closeSync(); +}); + +test(`${variant}: duckdbArrowInsertStream maps batch columns by physical column order when provided schema order differs`, async () => { + const db = await setupTable(); // physical order: id INTEGER, name VARCHAR + await pipeline([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]), + arrowBatchFromObjectStream({ + schema: usersArrowSchema, + batchSize: 2, + }), + await duckdbArrowInsertStream({ + db, + table: "users", + schema: reversedUsersSchema, + }), + ]); + const rows = await selectAll(db); + deepStrictEqual(rows, [ + [1, "alice"], + [2, "bob"], + ]); + db.closeSync(); +}); + +test(`${variant}: duckdbAppenderStream closes the appender when an upstream source errors AFTER init (no native handle leak)`, async () => { + let closed = false; + const appender = { + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id", "name"] }), + createAppender: async () => appender, + }; + async function* source() { + yield { id: 1, name: "alice" }; // creates the appender (init) + throw new Error("upstream boom"); + } + let caught; + try { + await pipeline([ + createReadableStream(source()), + await duckdbAppenderStream({ db, table: "users" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "the upstream error must propagate"); + ok(caught.message.includes("upstream boom")); + // The native appender handle must be released even though the error came + // from upstream (final never runs, write's catch never fires). + strictEqual(closed, true); +}); + +test(`${variant}: duckdbAppenderStream handles its own "error" event (absorbs it and releases the appender)`, async () => { + let closed = false; + const appender = { + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + const stream = await duckdbAppenderStream({ db, table: "users" }); + // Lazily create the native appender so cleanup has a handle to release. + await new Promise((resolve, reject) => { + stream.write({ id: 1 }, (error) => (error ? reject(error) : resolve())); + }); + // The stream registers its own "error" listener, so emitting "error" must be + // absorbed (not rethrown as an unhandled "error" event) and must release the + // appender. Without that listener, emit("error") throws here. + let threw = false; + try { + stream.emit("error", new Error("boom")); + } catch { + threw = true; + } + strictEqual(threw, false, 'the stream must handle its own "error" event'); + strictEqual(closed, true, "the appender handle must be released on error"); + stream.destroy(); +}); + +test(`${variant}: duckdbArrowInsertStream closes the appender when an upstream source errors AFTER init (no native handle leak)`, async () => { + let closed = false; + const appender = { + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id", "name"] }), + createAppender: async () => appender, + }; + // A passthrough-style fake batch (2 columns, matching the table) that + // triggers init() on first write, then the upstream errors before final(). + const fakeBatch = { + schema: { fields: [{ name: "id" }, { name: "name" }] }, + numRows: 1, + getChildAt: () => ({ get: () => 1 }), + }; + async function* source() { + yield fakeBatch; // creates the appender (init) + throw new Error("upstream batch boom"); + } + let caught; + try { + await pipeline([ + createReadableStream(source()), + await duckdbArrowInsertStream({ db, table: "users" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "the upstream error must propagate"); + ok(caught.message.includes("upstream batch boom")); + strictEqual(closed, true); +}); + +test(`${variant}: duckdbArrowInsertStream throws a descriptive error when the batch column count does not match the table`, async () => { + const db = await setupTable(); // table users(id, name) -> 2 columns + // A batch with only one column does not match the 2-column table. + const oneColSchema = new Schema([new Field("id", new Int32(), true)]); + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + arrowBatchFromObjectStream({ schema: oneColSchema, batchSize: 2 }), + await duckdbArrowInsertStream({ db, table: "users" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "a column-count mismatch must throw"); + ok( + caught.message.includes("column"), + `error must mention column mismatch, got: ${caught?.message}`, + ); + db.closeSync(); +}); + +// *** web duckdbArrowInsertStream (direct file import) *** // +// index.web.js's duckdb-wasm connect path requires a Worker-capable browser +// runtime, but the Arrow-IPC serialization path (the data-loss bug) can be +// exercised with a fake db that only records what is inserted. Node resolves +// @datastream/duckdb to the node build under both conditions, so import the web +// source directly to test the web code path. +const importWeb = () => + import(`file://${new URL("./index.web.js", import.meta.url).pathname}`); + +test(`${variant}: web duckdbArrowInsertStream inserts ALL record batches, not just the first`, async () => { + const { duckdbArrowInsertStream: webArrowInsertStream } = await importWeb(); + let inserted; + const db = { + // tableExists() probe — succeed so the table is treated as existing. + query: async () => ({ schema: { fields: usersArrowSchema.fields } }), + insertArrowTable: async (arrowTable) => { + inserted = arrowTable; + }, + }; + // batchSize 1 forces three separate RecordBatch objects (the bug merged + // three complete IPC streams and the reader stopped at the first EOS, + // dropping batches 2 and 3). + await pipeline([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + { id: 3, name: "carol" }, + ]), + arrowBatchFromObjectStream({ schema: usersArrowSchema, batchSize: 1 }), + await webArrowInsertStream({ + db, + table: "users", + schema: usersArrowSchema, + }), + ]); + ok(inserted, "insertArrowTable must be called"); + strictEqual(inserted.numRows, 3); + const idCol = inserted.getChild("id"); + deepStrictEqual([idCol.get(0), idCol.get(1), idCol.get(2)], [1, 2, 3]); +}); + +test(`${variant}: web duckdbAppenderStream sends object rows via a by-name prepared INSERT`, async () => { + const { duckdbAppenderStream: webAppenderStream } = await importWeb(); + let preparedSql; + let closed = false; + const sent = []; + const db = { + // tableExists() probe — succeed so the table is treated as existing. + query: async () => ({ schema: { fields: usersArrowSchema.fields } }), + prepare: async (sql) => { + preparedSql = sql; + return { + send: async (...values) => { + sent.push(values); + }, + close: async () => { + closed = true; + }, + }; + }, + }; + await pipeline([ + createReadableStream([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]), + await webAppenderStream({ db, table: "users" }), + ]); + // The web build binds by name: the INSERT must list the (physical) columns + // and the sent values must follow that same column order. + ok( + preparedSql.includes('INSERT INTO "users"'), + `unexpected SQL: ${preparedSql}`, + ); + ok(preparedSql.includes('"id"') && preparedSql.includes('"name"')); + deepStrictEqual(sent, [ + [1, "alice"], + [2, "bob"], + ]); + strictEqual(closed, true, "prepared statement must be closed"); +}); + +test(`${variant}: web duckdbAppenderStream maps object rows by physical column order when provided schema order differs`, async () => { + const { duckdbAppenderStream: webAppenderStream } = await importWeb(); + const sent = []; + // Physical column order reported by the table probe is [id, name]. + const db = { + query: async () => ({ schema: { fields: usersArrowSchema.fields } }), + prepare: async () => ({ + send: async (...values) => { + sent.push(values); + }, + close: async () => {}, + }), + }; + await pipeline([ + createReadableStream([{ id: 1, name: "alice" }]), + await webAppenderStream({ + db, + table: "users", + schema: reversedUsersSchema, + }), + ]); + // Even with a reversed provided schema, values follow physical order [id, name]. + deepStrictEqual(sent, [[1, "alice"]]); +}); + +// *** arrowTypeToDuckDBSQL coverage *** // +// Each Arrow type must map to its corresponding DuckDB SQL type name so that +// CREATE TABLE produces the right column definition. We exercise every branch +// through duckdbAppenderStream with a schema (which calls createTableFromArrowSchema +// then DESCRIBE to confirm the column type). + +const describeColumns = async (db, table) => { + const r = await db.runAndReadAll(`DESCRIBE ${table}`); + const rows = r.getRowsJS(); + return Object.fromEntries(rows.map((row) => [row[0], row[1]])); +}; + +test(`${variant}: arrowTypeToDuckDBSQL maps every Arrow type to the correct DuckDB column type`, async () => { + const db = await duckdbConnect(); + const allTypesSchema = new Schema([ + new Field("c_bool", new Bool(), true), + new Field("c_int8", new Int8(), true), + new Field("c_int16", new Int16(), true), + // Int32 is already exercised by the usersArrowSchema tests + new Field("c_int64", new Int64(), true), + new Field("c_uint8", new Uint8(), true), + new Field("c_uint16", new Uint16(), true), + new Field("c_uint32", new Uint32(), true), + new Field("c_uint64", new Uint64(), true), + new Field("c_float32", new Float32(), true), + new Field("c_float64", new Float64(), true), + new Field("c_date", new Date_(), true), + new Field("c_ts_s", new TimestampSecond(), true), + new Field("c_ts_ms", new TimestampMillisecond(), true), + new Field("c_ts_us", new TimestampMicrosecond(), true), + new Field("c_ts_ns", new TimestampNanosecond(), true), + new Field("c_str", new Utf8(), true), + ]); + await pipeline([ + createReadableStream([ + { + c_bool: null, + c_int8: null, + c_int16: null, + c_int64: null, + c_uint8: null, + c_uint16: null, + c_uint32: null, + c_uint64: null, + c_float32: null, + c_float64: null, + c_date: null, + c_ts_s: null, + c_ts_ms: null, + c_ts_us: null, + c_ts_ns: null, + c_str: null, + }, + ]), + await duckdbAppenderStream({ + db, + table: "all_types", + schema: allTypesSchema, + }), + ]); + const cols = await describeColumns(db, "all_types"); + strictEqual(cols.c_bool, "BOOLEAN"); + strictEqual(cols.c_int8, "TINYINT"); + strictEqual(cols.c_int16, "SMALLINT"); + strictEqual(cols.c_int64, "BIGINT"); + strictEqual(cols.c_uint8, "UTINYINT"); + strictEqual(cols.c_uint16, "USMALLINT"); + strictEqual(cols.c_uint32, "UINTEGER"); + strictEqual(cols.c_uint64, "UBIGINT"); + // DuckDB reports REAL as FLOAT (they are aliases) + strictEqual(cols.c_float32, "FLOAT"); + strictEqual(cols.c_float64, "DOUBLE"); + strictEqual(cols.c_date, "DATE"); + strictEqual(cols.c_ts_s, "TIMESTAMP_S"); + strictEqual(cols.c_ts_ms, "TIMESTAMP_MS"); + strictEqual(cols.c_ts_us, "TIMESTAMP"); + strictEqual(cols.c_ts_ns, "TIMESTAMP_NS"); + // Utf8 (and any unknown type) maps to VARCHAR + strictEqual(cols.c_str, "VARCHAR"); + db.closeSync(); +}); + +// *** quoteIdent coverage *** // + +test(`${variant}: quoteIdent escapes embedded double-quotes in table name`, async () => { + // A table name containing " must be doubled so the identifier is valid SQL. + const db = await duckdbConnect(); + const schema = new Schema([new Field("id", new Int32(), true)]); + await pipeline([ + createReadableStream([{ id: 42 }]), + await duckdbAppenderStream({ db, table: 'weird"table', schema }), + ]); + const reader = await db.runAndReadAll('SELECT id FROM "weird""table"'); + deepStrictEqual(reader.getRowsJS(), [[42]]); + db.closeSync(); +}); + +test(`${variant}: quoteIdent rejects an empty-string table name`, async () => { + const db = await duckdbConnect(); + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "empty table name must throw"); + ok(caught.message.includes("identifier must be a non-empty string")); + db.closeSync(); +}); + +// *** isMissingTableError coverage *** // + +test(`${variant}: non-table-missing error from db.run propagates instead of being swallowed`, async () => { + const lockError = new Error("lock timeout on resource"); + const db = { + run: async () => { + throw lockError; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "error must propagate"); + strictEqual(caught, lockError); +}); + +test(`${variant}: tableExists returns false for "does not exist" errors (triggers CREATE TABLE)`, async () => { + let createTableCalled = false; + const db = { + run: async (sql) => { + if (sql.includes("LIMIT 0")) { + throw new Error("Table with name t does not exist"); + } + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + ok( + createTableCalled, + "createTableFromArrowSchema must be called when table is absent", + ); +}); + +test(`${variant}: tableExists returns false for "not found" errors (triggers CREATE TABLE)`, async () => { + let createTableCalled = false; + const db = { + run: async (sql) => { + if (sql.includes("LIMIT 0")) { + throw new Error("Table not found"); + } + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + ok( + createTableCalled, + "createTableFromArrowSchema must be called on not found error", + ); +}); + +test(`${variant}: tableExists returns false for "catalog error" errors (triggers CREATE TABLE)`, async () => { + let createTableCalled = false; + const db = { + run: async (sql) => { + if (sql.includes("LIMIT 0")) { + throw new Error("Catalog error: table missing"); + } + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + ok( + createTableCalled, + "createTableFromArrowSchema must be called on catalog error", + ); +}); + +test(`${variant}: tableExists returns true when db.run succeeds (no CREATE TABLE called)`, async () => { + let createTableCalled = false; + const db = { + run: async (sql) => { + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + strictEqual( + createTableCalled, + false, + "CREATE TABLE must NOT be called when table already exists", + ); +}); + +// *** appendCell coverage *** // + +test(`${variant}: appendCell calls appendNull for null values and appendValue for non-null`, async () => { + let nullCount = 0; + const values = []; + const appender = { + appendNull: () => { + nullCount++; + }, + appendValue: (v) => { + values.push(v); + }, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["a", "b", "c"] }), + createAppender: async () => appender, + }; + await pipeline([ + createReadableStream([{ a: null, b: undefined, c: 99 }]), + await duckdbAppenderStream({ db, table: "t" }), + ]); + strictEqual(nullCount, 2, "null and undefined must both call appendNull"); + deepStrictEqual( + values, + [99], + "only the non-null value must call appendValue", + ); +}); + +test(`${variant}: appendCell calls appendNull for undefined values in array rows`, async () => { + let nullCount = 0; + const appender = { + appendNull: () => { + nullCount++; + }, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["x"] }), + createAppender: async () => appender, + }; + await pipeline([ + createReadableStream([[undefined]]), + await duckdbAppenderStream({ db, table: "t" }), + ]); + strictEqual(nullCount, 1, "undefined in array row must call appendNull"); +}); + +// *** closeAppenderOnce coverage *** // + +test(`${variant}: closeAppenderOnce is idempotent — closeSync called exactly once even after double invocation`, async () => { + let closeSyncCount = 0; + const appender = { + appendNull: () => {}, + appendValue: (v) => { + if (v === "boom") throw new Error("forced"); + }, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closeSyncCount++; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["v"] }), + createAppender: async () => appender, + }; + try { + await pipeline([ + createReadableStream([{ v: "boom" }]), + await duckdbAppenderStream({ db, table: "t" }), + ]); + } catch (_) {} + strictEqual(closeSyncCount, 1, "closeSync must be called exactly once"); +}); + +test(`${variant}: closeAppenderOnce sets state.closed to true — normal completion calls flushSync then closeSync once`, async () => { + let flushCount = 0; + let closeSyncCount = 0; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => { + flushCount++; + }, + closeSync: () => { + closeSyncCount++; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["v"] }), + createAppender: async () => appender, + }; + await pipeline([ + createReadableStream([{ v: 1 }]), + await duckdbAppenderStream({ db, table: "t" }), + ]); + strictEqual(flushCount, 1, "flushSync called once on normal completion"); + strictEqual(closeSyncCount, 1, "closeSync called once on normal completion"); +}); + +// *** final() coverage *** // + +test(`${variant}: duckdbAppenderStream final calls flushSync before closing the appender`, async () => { + const calls = []; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => { + calls.push("flush"); + }, + closeSync: () => { + calls.push("close"); + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t" }), + ]); + deepStrictEqual( + calls, + ["flush", "close"], + "flushSync must precede closeSync", + ); +}); + +test(`${variant}: duckdbArrowInsertStream final calls flushSync before closing the appender`, async () => { + const calls = []; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => { + calls.push("flush"); + }, + closeSync: () => { + calls.push("close"); + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id", "name"] }), + createAppender: async () => appender, + }; + const fakeBatch = { + schema: { fields: [{ name: "id" }, { name: "name" }] }, + numRows: 1, + getChildAt: () => ({ get: () => 1 }), + }; + await pipeline([ + createReadableStream([fakeBatch]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + deepStrictEqual( + calls, + ["flush", "close"], + "flushSync must precede closeSync in arrow stream", + ); +}); + +test(`${variant}: duckdbAppenderStream final does nothing when no rows were written`, async () => { + let closeSyncCalled = false; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closeSyncCalled = true; + }, + }), + }; + await pipeline([ + createReadableStream([]), + await duckdbAppenderStream({ db, table: "t" }), + ]); + strictEqual( + closeSyncCalled, + false, + "closeSync must not be called when no rows were written", + ); +}); + +// *** duckdbArrowInsertStream missing-column guard *** // + +test(`${variant}: duckdbArrowInsertStream throws when batch.getChildAt returns null`, async () => { + let closed = false; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id", "name"] }), + createAppender: async () => appender, + }; + const nullColBatch = { + schema: { fields: [{ name: "id" }, { name: "name" }] }, + numRows: 1, + getChildAt: () => null, + }; + let caught; + try { + await pipeline([ + createReadableStream([nullColBatch]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "missing column must throw"); + ok( + caught.message.includes("missing column"), + `error must mention missing column, got: ${caught?.message}`, + ); + strictEqual( + closed, + true, + "appender must be closed after missing-column error", + ); +}); + +test(`${variant}: duckdbArrowInsertStream throws when batch.getChildAt returns undefined`, async () => { + let closed = false; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + const undefinedColBatch = { + schema: { fields: [{ name: "id" }] }, + numRows: 1, + getChildAt: () => undefined, + }; + let caught; + try { + await pipeline([ + createReadableStream([undefinedColBatch]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "missing column (undefined) must throw"); + ok(caught.message.includes("missing column")); + strictEqual(closed, true); +}); + +// *** duckdbArrowInsertStream row/column loop counts *** // + +test(`${variant}: duckdbArrowInsertStream writes the correct number of cells per row (colCount loop)`, async () => { + const cellValues = []; + const appender = { + appendNull: () => {}, + appendValue: (v) => { + cellValues.push(v); + }, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["a", "b", "c"] }), + createAppender: async () => appender, + }; + const fakeBatch = { + schema: { fields: [{ name: "a" }, { name: "b" }, { name: "c" }] }, + numRows: 1, + getChildAt: (i) => ({ get: (r) => (r + 1) * 10 + i }), + }; + await pipeline([ + createReadableStream([fakeBatch]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + deepStrictEqual( + cellValues, + [10, 11, 12], + "all 3 columns must be appended for the row", + ); +}); + +test(`${variant}: duckdbArrowInsertStream writes all rows (rowCount loop)`, async () => { + let endRowCount = 0; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => { + endRowCount++; + }, + flushSync: () => {}, + closeSync: () => {}, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + const fakeBatch = { + schema: { fields: [{ name: "id" }] }, + numRows: 5, + getChildAt: () => ({ get: (r) => r }), + }; + await pipeline([ + createReadableStream([fakeBatch]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + strictEqual(endRowCount, 5, "endRow must be called once per row"); +}); + +test(`${variant}: duckdbArrowInsertStream column count mismatch error message includes batch and table counts`, async () => { + const db = await setupTable(); + const oneColSchema = new Schema([new Field("id", new Int32(), true)]); + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + arrowBatchFromObjectStream({ schema: oneColSchema, batchSize: 1 }), + await duckdbArrowInsertStream({ db, table: "users" }), + ]); + } catch (e) { + caught = e; + } + ok(caught); + ok( + caught.message.includes("1"), + `mismatch message must include batch col count (1), got: ${caught.message}`, + ); + ok( + caught.message.includes("2"), + `mismatch message must include table col count (2), got: ${caught.message}`, + ); + ok( + caught.message.includes("users"), + "mismatch message must include table name", + ); + db.closeSync(); +}); + +test(`${variant}: duckdbArrowInsertStream uses batch.numCols when schema.fields is absent`, async () => { + const db = await setupTable(); + const noSchemaBatch = { + schema: undefined, + numCols: 1, + numRows: 0, + getChildAt: () => ({ get: () => null }), + }; + let caught; + try { + await pipeline([ + createReadableStream([noSchemaBatch]), + await duckdbArrowInsertStream({ db, table: "users" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "mismatch from numCols fallback must throw"); + ok(caught.message.includes("column count")); + db.closeSync(); +}); + +// *** createTableFromArrowSchema column separator *** // + +test(`${variant}: createTableFromArrowSchema uses correct column separator in CREATE TABLE`, async () => { + const db = await duckdbConnect(); + const threeColSchema = new Schema([ + new Field("x", new Int32(), true), + new Field("y", new Int32(), true), + new Field("z", new Int32(), true), + ]); + await pipeline([ + createReadableStream([{ x: 1, y: 2, z: 3 }]), + await duckdbAppenderStream({ db, table: "triple", schema: threeColSchema }), + ]); + const reader = await db.runAndReadAll("SELECT x, y, z FROM triple"); + deepStrictEqual(reader.getRowsJS(), [[1, 2, 3]]); + db.closeSync(); +}); + +// *** default export *** // + +test(`${variant}: default export exposes connect, appenderStream, and arrowInsertStream`, async () => { + const mod = await import("@datastream/duckdb"); + const def = mod.default; + ok(def, "default export must exist"); + strictEqual( + typeof def.connect, + "function", + "default.connect must be a function", + ); + strictEqual( + typeof def.appenderStream, + "function", + "default.appenderStream must be a function", + ); + strictEqual( + typeof def.arrowInsertStream, + "function", + "default.arrowInsertStream must be a function", + ); +}); + +// *** wireAppenderCleanup — signal branch *** // + +test(`${variant}: duckdbAppenderStream closes appender when AbortSignal fires after init`, async () => { + let closed = false; + const controller = new AbortController(); + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => { + controller.abort(); + return appender; + }, + }; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream( + { db, table: "t" }, + { signal: controller.signal }, + ), + ]); + } catch (_) {} + strictEqual(closed, true, "appender must be closed when the signal fires"); +}); + +// *** ensureTableAndColumns — schema absent path *** // + +test(`${variant}: duckdbAppenderStream with no schema and existing table skips CREATE TABLE`, async () => { + let tableExistsProbed = false; + let createTableCalled = false; + const db = { + run: async (sql) => { + if (sql.includes("LIMIT 0")) tableExistsProbed = true; + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t" }), + ]); + strictEqual( + tableExistsProbed, + false, + "tableExists probe must be skipped when no schema", + ); + strictEqual( + createTableCalled, + false, + "CREATE TABLE must not be called without schema", + ); +}); + +// *** closeAppenderOnce — closeSync throws (error swallowed) *** // + +test(`${variant}: closeAppenderOnce swallows errors thrown by closeSync on the error path`, async () => { + // If the native appender's closeSync itself throws (e.g. already closed + // internally), the error must be swallowed so the original write error is + // what propagates to the caller. + const appender = { + appendValue: (v) => { + if (v === "boom") throw new Error("write error"); + }, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + throw new Error("closeSync also failed"); + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["v"] }), + createAppender: async () => appender, + }; + let caught; + try { + await pipeline([ + createReadableStream([{ v: "boom" }]), + await duckdbAppenderStream({ db, table: "t" }), + ]); + } catch (e) { + caught = e; + } + // The WRITE error must propagate, not the closeSync error. + ok(caught, "write error must propagate"); + ok( + caught.message.includes("write error"), + `expected write error, got: ${caught?.message}`, + ); +}); + +// *** isMissingTableError — string-thrown error fallback *** // + +test(`${variant}: tableExists returns false when db.run throws a plain string containing "does not exist"`, async () => { + // isMissingTableError uses (error?.message ?? error) — when the thrown value + // is a plain string (not an Error object), error.message is undefined and + // the ?? fallback path uses the string itself. This covers the right-hand + // side of the ?? operator. + let createTableCalled = false; + const db = { + run: async (sql) => { + if (sql.includes("LIMIT 0")) { + // Throw a plain string, not an Error — error.message will be undefined, + // triggering the ?? fallback branch in isMissingTableError. + throw "Table does not exist"; + } + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + ok( + createTableCalled, + "CREATE TABLE must be called when string error matches", + ); +}); + +// *** wireAppenderCleanup — pre-aborted signal path *** // + +test(`${variant}: duckdbAppenderStream with pre-aborted signal closes appender when signal is already aborted at wiring time`, async () => { + // When the AbortSignal is ALREADY aborted at the moment wireAppenderCleanup + // runs (i.e. signal.aborted === true), cleanup() must be called immediately + // via the signal.aborted branch rather than attaching an event listener. + // We inject an appender that is already set on the state object by + // manipulating the db so createAppender resolves before wireAppenderCleanup. + // The simplest way is: abort the controller BEFORE calling duckdbAppenderStream + // and verify the stream construction succeeds without throwing (the branch + // is a no-op when state.appender is still undefined, but the branch IS taken). + const controller = new AbortController(); + controller.abort(); // pre-aborted BEFORE duckdbAppenderStream is called + let appenderCreated = false; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => { + appenderCreated = true; + return { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }; + }, + }; + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream( + { db, table: "t" }, + { signal: controller.signal }, + ), + ]); + } catch (e) { + caught = e; + } + // The stream construction itself must not throw — the pre-aborted signal + // branch (line 103) is taken at wiring time and is a no-op when no appender + // exists yet. The pipeline may fail due to the abort, which is fine. + ok( + caught === undefined || caught instanceof Error, + "pre-aborted signal must not prevent stream construction", + ); + // The appender was created during write (after construction), confirming the + // signal.aborted branch was evaluated before any write occurred. + strictEqual( + typeof appenderCreated, + "boolean", + "branch reached: signal.aborted check executed at construction time", + ); +}); + +// *** duckdbConnect — path validation *** // + +test(`${variant}: duckdbConnect defaults to an in-memory database and rejects an empty path`, async () => { + // The default ":memory:" must give a working in-memory connection... + const db = await duckdbConnect(); + await db.run("CREATE TABLE m (x INTEGER); INSERT INTO m VALUES (7)"); + const reader = await db.runAndReadAll("SELECT x FROM m"); + deepStrictEqual(reader.getRowsJS(), [[7]]); + db.closeSync(); + // ...and an empty path must be rejected (so the ":memory:" default cannot be + // silently replaced by ""). + let caught; + try { + await duckdbConnect(""); + } catch (e) { + caught = e; + } + ok(caught, "an empty path must throw"); + ok( + caught.message.includes("non-empty string"), + `unexpected error: ${caught?.message}`, + ); + // A non-string path (which has no string length) must also be rejected by the + // `typeof path !== "string"` half of the guard, with the SAME message — not be + // forwarded to DuckDBInstance.create. + let caughtNonString; + try { + await duckdbConnect(42); + } catch (e) { + caughtNonString = e; + } + ok(caughtNonString, "a non-string path must throw"); + ok( + caughtNonString instanceof TypeError && + caughtNonString.message.includes("non-empty string"), + `non-string path must hit the path guard, got: ${caughtNonString?.message}`, + ); +}); + +// *** isMissingTableError — non-missing error must propagate AND must NOT trigger CREATE TABLE *** // + +test(`${variant}: a non-missing probe error propagates and does NOT cause a spurious CREATE TABLE`, async () => { + // The probe (SELECT ... LIMIT 0) fails with a NON-missing error (e.g. a lock). + // isMissingTableError must return false so the error propagates; CREATE TABLE + // must NOT be attempted. If isMissingTableError were to return true (or the + // substring checks degenerated to always-true, or the catch swallowed the + // error), the error would be hidden and CREATE TABLE would run instead. + let createTableCalled = false; + const probeError = new Error("IO Error: could not read lock on resource"); + const db = { + run: async (sql) => { + if (sql.includes("LIMIT 0")) throw probeError; + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + } catch (e) { + caught = e; + } + strictEqual(caught, probeError, "the non-missing probe error must propagate"); + strictEqual( + createTableCalled, + false, + "CREATE TABLE must NOT run when the probe failed with a non-missing error", + ); +}); + +test(`${variant}: isMissingTableError requires the FULL "does not exist" phrase, not a prefix`, async () => { + // A message that does NOT contain "does not exist"/"not found"/"catalog error" + // must be treated as a real error. This pins the exact literals: blanking any + // of them would make includes("") always true and wrongly swallow this error. + let createTableCalled = false; + const probeError = new Error("permission denied for relation t"); + const db = { + run: async (sql) => { + if (sql.includes("LIMIT 0")) throw probeError; + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + } catch (e) { + caught = e; + } + strictEqual(caught, probeError); + strictEqual(createTableCalled, false); +}); + +test(`${variant}: isMissingTableError tolerates a nullish probe error (optional chaining on error.message)`, async () => { + // When the probe throws a nullish value (null), error?.message must short-circuit + // to undefined and fall back to String(error) === "null" — which is NOT a + // missing-table match, so tableExists re-throws the (nullish) value. A nullish + // stream error is treated as success by the stream layer, so the pipeline + // completes WITHOUT a CREATE TABLE. If the optional chaining were removed + // (error.message), reading .message on null would throw a TypeError — a + // truthy error that WOULD reject the pipeline. Asserting the pipeline does NOT + // reject with a TypeError pins the optional chaining. + let createTableCalled = false; + const db = { + run: async (sql) => { + if (sql.includes("LIMIT 0")) { + throw null; + } + if (sql.startsWith("CREATE TABLE")) createTableCalled = true; + }, + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + const schema = new Schema([new Field("id", new Int32(), true)]); + let caught; + try { + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + } catch (e) { + caught = e ?? new Error("nullish rejection"); + } + ok( + !(caught instanceof TypeError), + `a TypeError means optional chaining on error.message was lost: ${caught}`, + ); + strictEqual( + createTableCalled, + false, + "a nullish (non-missing) probe error must not trigger CREATE TABLE", + ); +}); + +// *** arrowTypeToDuckDBSQL — null/undefined field type maps to VARCHAR (optional chaining) *** // + +test(`${variant}: arrowTypeToDuckDBSQL maps a field with no type to VARCHAR (does not crash)`, async () => { + // A field whose .type is undefined must resolve to VARCHAR via the optional + // chaining on type?.constructor?.name. Dropping the chaining would throw when + // reading .constructor of undefined, breaking CREATE TABLE. + const ddl = []; + const db = { + run: async (sql) => { + if (sql.startsWith("CREATE TABLE")) ddl.push(sql); + // table-existence probe: report missing so CREATE TABLE runs + if (sql.includes("LIMIT 0")) throw new Error("table does not exist"); + }, + runAndReadAll: async () => ({ columnNames: () => ["a", "b"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }), + }; + // A plain schema object (passed through resolveLazy unchanged) where one field + // has no constructor at all (a null-prototype object: type.constructor is + // undefined) and one field whose type is null. Both must map to VARCHAR. The + // null-prototype case specifically pins the SECOND optional chaining + // (constructor?.name): reading `.name` off an undefined constructor would throw. + const schema = { + fields: [ + { name: "a", type: Object.create(null) }, + { name: "b", type: null }, + ], + }; + await pipeline([ + createReadableStream([{ a: "x", b: "y" }]), + await duckdbAppenderStream({ db, table: "t", schema }), + ]); + strictEqual(ddl.length, 1, "CREATE TABLE must run for the missing table"); + ok( + ddl[0].includes('"a" VARCHAR'), + `field with undefined type must be VARCHAR, got: ${ddl[0]}`, + ); + ok( + ddl[0].includes('"b" VARCHAR'), + `field with null type must be VARCHAR, got: ${ddl[0]}`, + ); +}); + +// *** wireAppenderCleanup — signal listener closes the appender SYNCHRONOUSLY on abort *** // +// The signal listener path is distinct from the stream's own error/close path: +// when the signal fires while the stream is still live, the appender must close +// immediately via the addEventListener("abort", cleanup) listener, BEFORE the +// stream emits its async "close". This isolates the signal branch (which the +// native Writable's own abort handling would otherwise mask). + +const buildSignalCloseProbe = (factory, chunk) => async () => { + let closed = false; + let closeEventFired = false; + const controller = new AbortController(); + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => { + closed = true; + }, + }; + const stream = await factory(appender, controller.signal); + stream.on("close", () => { + closeEventFired = true; + }); + // Write one row/batch so init() creates the appender (state.appender is set). + await new Promise((resolve, reject) => + stream.write(chunk, (e) => (e ? reject(e) : resolve())), + ); + strictEqual(closed, false, "appender must be open before the signal fires"); + controller.abort(); + // SYNCHRONOUSLY after abort: the signal listener must already have closed the + // appender, and the stream's async "close" must not have fired yet. + strictEqual( + closeEventFired, + false, + "the stream's async close must not have fired synchronously", + ); + strictEqual( + closed, + true, + "the abort listener must close the appender synchronously on abort", + ); + stream.destroy(); +}; + +test( + `${variant}: duckdbAppenderStream closes the appender synchronously via the abort listener`, + buildSignalCloseProbe( + async (appender, signal) => { + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + return duckdbAppenderStream({ db, table: "t" }, { signal }); + }, + { id: 1 }, + ), +); + +test( + `${variant}: duckdbArrowInsertStream closes the appender synchronously via the abort listener`, + buildSignalCloseProbe( + async (appender, signal) => { + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + return duckdbArrowInsertStream({ db, table: "t" }, { signal }); + }, + { + schema: { fields: [{ name: "id" }] }, + numRows: 1, + getChildAt: () => ({ get: () => 1 }), + }, + ), +); + +// *** wireAppenderCleanup — abort listener is removed once the stream closes *** // + +test(`${variant}: duckdbAppenderStream removes the abort listener after the stream closes`, async () => { + // After a clean completion, the "close" handler must call + // signal.removeEventListener so a later abort does NOT re-run cleanup. We spy + // on the real AbortSignal to confirm removeEventListener("abort", ...) is + // invoked with the same listener that was added. + const controller = new AbortController(); + const added = []; + const removed = []; + // The signal is also handed to the core Writable, which adds/removes its OWN + // abort listener. Identify wireAppenderCleanup's listener by its source (it + // calls closeAppenderOnce) so we assert specifically about that listener. + const isCleanup = (listener) => + typeof listener === "function" && + listener.toString().includes("closeAppenderOnce"); + const realAdd = controller.signal.addEventListener.bind(controller.signal); + const realRemove = controller.signal.removeEventListener.bind( + controller.signal, + ); + controller.signal.addEventListener = (type, listener, opts) => { + if (type === "abort" && isCleanup(listener)) added.push(listener); + return realAdd(type, listener, opts); + }; + controller.signal.removeEventListener = (type, listener, opts) => { + if (type === "abort" && isCleanup(listener)) removed.push(listener); + return realRemove(type, listener, opts); + }; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + await pipeline([ + createReadableStream([{ id: 1 }]), + await duckdbAppenderStream( + { db, table: "t" }, + { signal: controller.signal }, + ), + ]); + strictEqual(added.length, 1, "exactly one abort listener must be added"); + deepStrictEqual( + removed, + added, + "the same abort listener that was added must be removed on close", + ); +}); + +// *** wireAppenderCleanup — abort listener is registered with { once: true } *** // + +test(`${variant}: duckdbAppenderStream registers the abort listener with { once: true }`, async () => { + // The abort listener must be a one-shot ({ once: true }); otherwise a repeated + // abort dispatch could re-enter cleanup. Capture the options passed to + // addEventListener("abort", ...) and assert once === true. + const controller = new AbortController(); + let capturedOptions; + // Capture only wireAppenderCleanup's own abort listener (it references + // closeAppenderOnce); the core Writable registers a separate abort listener. + const isCleanup = (listener) => + typeof listener === "function" && + listener.toString().includes("closeAppenderOnce"); + const realAdd = controller.signal.addEventListener.bind(controller.signal); + controller.signal.addEventListener = (type, listener, opts) => { + if (type === "abort" && isCleanup(listener)) capturedOptions = opts; + return realAdd(type, listener, opts); + }; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + const stream = await duckdbAppenderStream( + { db, table: "t" }, + { signal: controller.signal }, + ); + // One write to create the appender and register the listener. + await new Promise((resolve, reject) => + stream.write({ id: 1 }, (e) => (e ? reject(e) : resolve())), + ); + ok(capturedOptions, "abort listener options object must be provided"); + strictEqual( + capturedOptions.once, + true, + "abort listener must be registered with { once: true }", + ); + stream.destroy(); +}); + +// *** wireAppenderCleanup — stream "close" listener closes the appender *** // + +test(`${variant}: duckdbAppenderStream closes the appender via the stream "close" event (destroy without error)`, async () => { + // Destroying the stream WITHOUT an error must still release the native + // appender. This exercises the stream.once("close", cleanup) listener in + // isolation: no write error, no abort signal, no final() — only "close". + let closed = false; + let flushed = false; + const appender = { + appendNull: () => {}, + appendValue: () => {}, + endRow: () => {}, + flushSync: () => { + flushed = true; + }, + closeSync: () => { + closed = true; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + const stream = await duckdbAppenderStream({ db, table: "t" }); + // Write one row to create the appender. + await new Promise((resolve, reject) => + stream.write({ id: 1 }, (e) => (e ? reject(e) : resolve())), + ); + strictEqual(closed, false, "appender must still be open before close"); + // destroy() with no error: emits "close" but NOT "error", and skips final(). + stream.destroy(); + await new Promise((resolve) => stream.once("close", resolve)); + strictEqual( + closed, + true, + "the stream close listener must release the appender", + ); + strictEqual( + flushed, + false, + "final() must not have run on a destroy (no flush)", + ); +}); + +// *** duckdbArrowInsertStream — schema?.fields optional chaining + Array(colCount) *** // + +test(`${variant}: duckdbArrowInsertStream falls back to numCols when batch.schema is present but has no fields`, async () => { + // batch.schema?.fields?.length: when schema exists but fields is undefined, + // the SECOND optional chaining (fields?.length) must short-circuit to undefined + // so the ?? falls back to batch.numCols. Removing fields?.length would throw on + // undefined.length. Here numCols matches the table (1) so the write succeeds. + const cells = []; + const appender = { + appendNull: () => {}, + appendValue: (v) => { + cells.push(v); + }, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => appender, + }; + const batch = { + schema: {}, // present, but no `fields` property + numCols: 1, + numRows: 2, + getChildAt: () => ({ get: (r) => r + 100 }), + }; + await pipeline([ + createReadableStream([batch]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + deepStrictEqual( + cells, + [100, 101], + "numCols fallback must let both rows append their single column", + ); +}); + +test(`${variant}: duckdbArrowInsertStream appends every column in order`, async () => { + // The cols buffer is built by pushing each batch column in order; the row loop + // then appends cols[i].get(r). A 3-column, 1-row batch must append exactly the + // three column values in column order (a stray leading element would shift the + // values and break the .get() reads). + const cells = []; + const appender = { + appendNull: () => {}, + appendValue: (v) => { + cells.push(v); + }, + endRow: () => {}, + flushSync: () => {}, + closeSync: () => {}, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["a", "b", "c"] }), + createAppender: async () => appender, + }; + const batch = { + schema: { fields: [{ name: "a" }, { name: "b" }, { name: "c" }] }, + numRows: 1, + getChildAt: (i) => ({ get: () => `v${i}` }), + }; + await pipeline([ + createReadableStream([batch]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + deepStrictEqual( + cells, + ["v0", "v1", "v2"], + "all three preallocated columns must be appended in order", + ); +}); + +// *** final() guard — appender exists and is not already closed *** // + +test(`${variant}: duckdbArrowInsertStream final does NOT flush again after a write error already closed the appender`, async () => { + // final()'s guard `state.appender && !state.closed` must be honoured. After a + // write error closes the appender (state.closed = true), a subsequent final() + // must NOT call flushSync again. Mutating the guard to `true` or `||` would + // re-flush a closed appender. + let flushCount = 0; + let closeCount = 0; + const appender = { + appendNull: () => {}, + appendValue: () => { + throw new Error("cell boom"); + }, + endRow: () => {}, + flushSync: () => { + flushCount++; + }, + closeSync: () => { + closeCount++; + }, + }; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id", "name"] }), + createAppender: async () => appender, + }; + const batch = { + schema: { fields: [{ name: "id" }, { name: "name" }] }, + numRows: 1, + getChildAt: () => ({ get: () => 1 }), + }; + let caught; + try { + await pipeline([ + createReadableStream([batch]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + } catch (e) { + caught = e; + } + ok(caught, "the write error must propagate"); + strictEqual(flushCount, 0, "flushSync must NOT run after a failed write"); + strictEqual(closeCount, 1, "the appender must be closed exactly once"); +}); + +test(`${variant}: duckdbArrowInsertStream final does nothing when no batches were written`, async () => { + // With an empty source, init() never runs and state.appender stays undefined. + // final()'s guard `state.appender && !state.closed` must short-circuit on the + // undefined appender. Mutating the guard to `true` (or `&&`->`||`) would call + // state.appender.flushSync() on undefined and throw. + let flushCalled = false; + let closeCalled = false; + const db = { + runAndReadAll: async () => ({ columnNames: () => ["id"] }), + createAppender: async () => ({ + appendValue: () => {}, + appendNull: () => {}, + endRow: () => {}, + flushSync: () => { + flushCalled = true; + }, + closeSync: () => { + closeCalled = true; + }, + }), + }; + let caught; + try { + await pipeline([ + createReadableStream([]), + await duckdbArrowInsertStream({ db, table: "t" }), + ]); + } catch (e) { + caught = e; + } + strictEqual(caught, undefined, "empty arrow stream must complete cleanly"); + strictEqual(flushCalled, false, "flushSync must not run for an empty stream"); + strictEqual(closeCalled, false, "closeSync must not run for an empty stream"); +}); diff --git a/packages/duckdb/index.tst.ts b/packages/duckdb/index.tst.ts new file mode 100644 index 0000000..a2b1193 --- /dev/null +++ b/packages/duckdb/index.tst.ts @@ -0,0 +1,50 @@ +/// +/// +import { + duckdbAppenderStream, + duckdbArrowInsertStream, + duckdbConnect, +} from "@datastream/duckdb"; +import type { Schema } from "apache-arrow"; +import { describe, expect, test } from "tstyche"; + +const db: unknown = {}; +const schema = {} as Schema; + +describe("duckdbConnect", () => { + test("accepts no args", () => { + expect(duckdbConnect()).type.not.toBeAssignableTo(); + }); + + test("accepts path and options", () => { + expect( + duckdbConnect(":memory:", { access_mode: "READ_WRITE" }), + ).type.not.toBeAssignableTo(); + }); + + test("returns a Promise", () => { + expect(duckdbConnect()).type.toBe>(); + }); +}); + +describe("duckdbAppenderStream", () => { + test("requires db and table", () => { + expect( + duckdbAppenderStream({ db, table: "t" }), + ).type.not.toBeAssignableTo(); + }); + + test("accepts schema", () => { + expect( + duckdbAppenderStream({ db, table: "t", schema }), + ).type.not.toBeAssignableTo(); + }); +}); + +describe("duckdbArrowInsertStream", () => { + test("requires db and table", () => { + expect( + duckdbArrowInsertStream({ db, table: "t" }), + ).type.not.toBeAssignableTo(); + }); +}); diff --git a/packages/duckdb/index.web.js b/packages/duckdb/index.web.js new file mode 100644 index 0000000..b500d0b --- /dev/null +++ b/packages/duckdb/index.web.js @@ -0,0 +1,186 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { createWritableStream, resolveLazy } from "@datastream/core"; + +export const duckdbConnect = async (..._args) => { + const { AsyncDuckDB, ConsoleLogger, getJsDelivrBundles, selectBundle } = + await import("@duckdb/duckdb-wasm"); + const bundle = await selectBundle(getJsDelivrBundles()); + const worker = new Worker(bundle.mainWorker); + const db = new AsyncDuckDB(new ConsoleLogger(), worker); + await db.instantiate(bundle.mainModule, bundle.pthreadWorker); + const connection = await db.connect(); + connection.__duckdb = db; + return connection; +}; + +// DuckDB has no parameter binding for identifiers, so table/column names are +// interpolated into SQL. Double the embedded quotes and require a non-empty +// string so a crafted name can't break out of the quoted identifier. +const quoteIdent = (name) => { + if (typeof name !== "string" || name.length === 0) { + throw new TypeError("duckdb: identifier must be a non-empty string"); + } + return `"${name.replaceAll('"', '""')}"`; +}; + +// A failed existence probe should only be read as "table does not exist" when +// the error is a missing-table/catalog error. Any other failure (lock, +// permission, column read error, ...) must propagate so it is not masked by a +// confusing downstream "table already exists" from CREATE TABLE. +const isMissingTableError = (error) => { + const message = String(error?.message ?? error).toLowerCase(); + return ( + message.includes("does not exist") || + message.includes("not found") || + message.includes("catalog error") + ); +}; + +const tableExists = async (db, table) => { + try { + await db.query(`SELECT 1 FROM ${quoteIdent(table)} LIMIT 0`); + return true; + } catch (error) { + if (isMissingTableError(error)) return false; + throw error; + } +}; + +const fetchColumnNames = async (db, table) => { + const result = await db.query(`SELECT * FROM ${quoteIdent(table)} LIMIT 0`); + return result.schema.fields.map((f) => f.name); +}; + +const arrowTypeToDuckDBSQL = (type) => { + const name = type?.constructor?.name; + switch (name) { + case "Bool": + return "BOOLEAN"; + case "Int8": + return "TINYINT"; + case "Int16": + return "SMALLINT"; + case "Int32": + return "INTEGER"; + case "Int64": + return "BIGINT"; + case "Uint8": + return "UTINYINT"; + case "Uint16": + return "USMALLINT"; + case "Uint32": + return "UINTEGER"; + case "Uint64": + return "UBIGINT"; + case "Float32": + return "REAL"; + case "Float64": + return "DOUBLE"; + case "Date_": + return "DATE"; + case "TimestampSecond": + return "TIMESTAMP_S"; + case "TimestampMillisecond": + return "TIMESTAMP_MS"; + case "TimestampMicrosecond": + return "TIMESTAMP"; + case "TimestampNanosecond": + return "TIMESTAMP_NS"; + default: + return "VARCHAR"; + } +}; + +const createTableFromArrowSchema = async (db, table, schema) => { + const cols = schema.fields + .map((f) => `${quoteIdent(f.name)} ${arrowTypeToDuckDBSQL(f.type)}`) + .join(", "); + await db.query(`CREATE TABLE ${quoteIdent(table)} (${cols})`); +}; + +const ensureTableAndColumns = async (db, table, schema) => { + if (schema && !(await tableExists(db, table))) { + await createTableFromArrowSchema(db, table, schema); + } + // Always derive the column order from the table's PHYSICAL layout so node and + // web agree: a provided schema whose field order differs from the physical + // column order must not change which value lands in which column. + return fetchColumnNames(db, table); +}; + +const buildInsertSQL = (table, columnNames) => { + const cols = columnNames.map((n) => quoteIdent(n)).join(", "); + const params = columnNames.map(() => "?").join(", "); + return `INSERT INTO ${quoteIdent(table)} (${cols}) VALUES (${params})`; +}; + +export const duckdbAppenderStream = async ( + { db, table, schema }, + streamOptions = {}, +) => { + let prepared; + let columnNames; + + const init = async () => { + const resolvedSchema = resolveLazy(schema); + columnNames = await ensureTableAndColumns(db, table, resolvedSchema); + prepared = await db.prepare(buildInsertSQL(table, columnNames)); + }; + + const write = async (row) => { + if (!prepared) await init(); + const isArray = Array.isArray(row); + const values = new Array(columnNames.length); + for (let i = 0, l = columnNames.length; i < l; i++) { + values[i] = isArray ? row[i] : row[columnNames[i]]; + } + await prepared.send(...values); + }; + const final = async () => { + if (prepared) await prepared.close(); + }; + + return createWritableStream(write, final, streamOptions); +}; + +export const duckdbArrowInsertStream = async ( + { db, table, schema }, + streamOptions = {}, +) => { + let initialized = false; + let columnNames; + const batches = []; + + const init = async () => { + const resolvedSchema = resolveLazy(schema); + columnNames = await ensureTableAndColumns(db, table, resolvedSchema); + initialized = true; + }; + + const { tableFromIPC, tableToIPC, Table } = await import("apache-arrow"); + + const write = async (batch) => { + if (!initialized) await init(); + // Accumulate the RecordBatch objects themselves. Serializing each batch as + // its own complete IPC stream and byte-concatenating them would produce + // multiple EOS markers; tableFromIPC stops at the first, silently dropping + // every batch after the first. Build one Table from all batches instead. + batches.push(batch); + }; + const final = async () => { + if (!batches.length) return; + const ipc = tableToIPC(new Table(batches), "stream"); + const arrowTable = tableFromIPC(ipc); + await db.insertArrowTable(arrowTable, { name: table, create: false }); + void columnNames; + }; + + return createWritableStream(write, final, streamOptions); +}; + +export default { + connect: duckdbConnect, + appenderStream: duckdbAppenderStream, + arrowInsertStream: duckdbArrowInsertStream, +}; diff --git a/packages/duckdb/package.json b/packages/duckdb/package.json new file mode 100644 index 0000000..d63c81d --- /dev/null +++ b/packages/duckdb/package.json @@ -0,0 +1,91 @@ +{ + "name": "@datastream/duckdb", + "version": "0.6.1", + "description": "DuckDB writable streams (copy-from) for Node and browser", + "type": "module", + "engines": { + "node": ">=24" + }, + "engineStrict": true, + "publishConfig": { + "access": "public" + }, + "main": "./index.node.mjs", + "module": "./index.web.mjs", + "exports": { + ".": { + "node": { + "import": { + "types": "./index.d.ts", + "default": "./index.node.mjs" + } + }, + "import": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "./webstream": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "types": "index.d.ts", + "files": [ + "*.mjs", + "*.map", + "*.d.ts" + ], + "scripts": { + "test": "npm run test:unit", + "test:unit": "node --test", + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" + }, + "license": "MIT", + "keywords": [ + "duckdb", + "sql", + "writable", + "Web Stream API", + "Node Stream API" + ], + "author": { + "name": "datastream contributors", + "url": "https://github.com/willfarrell/datastream/graphs/contributors" + }, + "repository": { + "type": "git", + "url": "github:willfarrell/datastream", + "directory": "packages/duckdb" + }, + "bugs": { + "url": "https://github.com/willfarrell/datastream/issues" + }, + "homepage": "https://datastream.js.org", + "dependencies": { + "@datastream/core": "0.6.1" + }, + "peerDependencies": { + "@duckdb/duckdb-wasm": "^1.33.1-dev57.0", + "@duckdb/node-api": "^1.5.4-r.1", + "apache-arrow": "21.1.0" + }, + "peerDependenciesMeta": { + "@duckdb/duckdb-wasm": { + "optional": true + }, + "@duckdb/node-api": { + "optional": true + }, + "apache-arrow": { + "optional": true + } + }, + "devDependencies": { + "@datastream/arrow": "0.6.1", + "@duckdb/duckdb-wasm": "1.33.1-dev57.0", + "@duckdb/node-api": "1.5.4-r.1", + "apache-arrow": "21.1.0" + } +} diff --git a/packages/encrypt/index.d.ts b/packages/encrypt/index.d.ts index 766347a..b2e01ff 100644 --- a/packages/encrypt/index.d.ts +++ b/packages/encrypt/index.d.ts @@ -26,6 +26,7 @@ export function encryptStream( iv?: Uint8Array | Buffer; aad?: Uint8Array | Buffer; maxInputSize?: number; + resultKey?: string; }, streamOptions?: StreamOptions, ): EncryptStreamResult | Promise; diff --git a/packages/encrypt/index.node.js b/packages/encrypt/index.node.js index d2a0a0b..2bf7593 100644 --- a/packages/encrypt/index.node.js +++ b/packages/encrypt/index.node.js @@ -1,19 +1,34 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { createTransformStream } from "@datastream/core"; const algorithmMap = { - "AES-256-GCM": { cipher: "aes-256-gcm", ivSize: 12 }, - "AES-256-CTR": { cipher: "aes-256-ctr", ivSize: 16 }, - "CHACHA20-POLY1305": { cipher: "chacha20-poly1305", ivSize: 12 }, + "AES-128-GCM": { cipher: "aes-128-gcm", ivSize: 12, keySize: 16 }, + "AES-256-GCM": { cipher: "aes-256-gcm", ivSize: 12, keySize: 32 }, + "AES-128-CTR": { cipher: "aes-128-ctr", ivSize: 16, keySize: 16 }, + "AES-256-CTR": { cipher: "aes-256-ctr", ivSize: 16, keySize: 32 }, + "CHACHA20-POLY1305": { cipher: "chacha20-poly1305", ivSize: 12, keySize: 32 }, }; -const authAlgorithms = ["AES-256-GCM", "CHACHA20-POLY1305"]; +const authAlgorithms = ["AES-128-GCM", "AES-256-GCM", "CHACHA20-POLY1305"]; +const ctrAlgorithms = ["AES-128-CTR", "AES-256-CTR"]; -const validateKey = (key) => { - if (!key || key.length !== 32) { +const DEFAULT_MAX_INPUT_SIZE = 64 * 1024 * 1024; // 64MB + +// NOTE on nonce/IV uniqueness for the AEAD modes (AES-256-GCM, +// CHACHA20-POLY1305): the default IV is a fresh 96-bit CSPRNG value, which is +// safe for a bounded number of messages per key. With random 96-bit nonces the +// birthday bound makes a collision non-negligible after ~2^32 encryptions under +// a single key, and a single GCM nonce reuse is catastrophic (enables forgery +// and authentication-key recovery). Rotate the key well before 2^32 messages, +// or supply a unique deterministic nonce per message. Never reuse an explicit +// iv with the same key. + +const validateKey = (key, keySize = 32) => { + if (!key || key.length !== keySize) { throw new Error( - `Encryption key must be 32 bytes (256 bits), got ${key?.length ?? 0}`, + `Encryption key must be ${keySize} bytes (${keySize * 8} bits), got ${key?.length ?? 0}`, ); } }; @@ -27,49 +42,68 @@ const validateIv = (iv, expectedSize, algorithm) => { }; const validateAuthTag = (authTag, algorithm) => { - if (!authTag || authTag.length !== 16) { + if (authTag?.length !== 16) { throw new Error( `authTag for ${algorithm} must be 16 bytes, got ${authTag?.length ?? 0}`, ); } }; -const validateAad = (aad) => { +const validateAad = (aad, algorithm) => { if (aad != null && !Buffer.isBuffer(aad) && !(aad instanceof Uint8Array)) { throw new Error("aad must be a Buffer or Uint8Array"); } + // AAD only has meaning for authenticated modes. Silently dropping it for + // AES-256-CTR would give the caller a false sense of integrity binding. + if (aad != null && !authAlgorithms.includes(algorithm)) { + throw new Error( + `aad is not supported for ${algorithm} (not authenticated)`, + ); + } }; export const encryptStream = ( - { algorithm = "AES-256-GCM", key, iv, aad, maxInputSize } = {}, + { algorithm = "AES-256-GCM", key, iv, aad, maxInputSize, resultKey } = {}, streamOptions = {}, ) => { const config = algorithmMap[algorithm]; if (!config) { throw new Error(`Unsupported algorithm: ${algorithm}`); } - const { cipher: cipherName, ivSize } = config; - validateKey(key); + const { cipher: cipherName, ivSize, keySize } = config; + validateKey(key, keySize); iv ??= randomBytes(ivSize); validateIv(iv, ivSize, algorithm); - validateAad(aad); + validateAad(aad, algorithm); const authTagLength = authAlgorithms.includes(algorithm) ? 16 : undefined; const stream = createCipheriv(cipherName, key, iv, { ...streamOptions, authTagLength, }); - if (aad && authAlgorithms.includes(algorithm)) { + if (aad != null && authAlgorithms.includes(algorithm)) { stream.setAAD(aad); } - if (maxInputSize !== null && maxInputSize !== undefined) { - let inputSize = 0; + // Input-size guard: + // - When maxInputSize is supplied explicitly, enforce it for all algorithms. + // - AEAD modes default to 64MB (DoS guard; web build buffers the whole + // ciphertext, so parity is important). + // - AES-*-CTR with no explicit maxInputSize: OpenSSL silently wraps its + // 128-bit counter at 2^128 blocks, but that is so far beyond any practical + // workload that no runtime guard is needed. Skip the transform override + // to avoid dead-code for an unreachable ceiling. + const skipInputGuard = + ctrAlgorithms.includes(algorithm) && maxInputSize == null; + if (!skipInputGuard) { + const effectiveMaxInput = maxInputSize ?? DEFAULT_MAX_INPUT_SIZE; + let inputSize = 0n; + const limit = BigInt(effectiveMaxInput); const originalWrite = stream._transform.bind(stream); stream._transform = (chunk, encoding, callback) => { - inputSize += chunk.length; - if (inputSize > maxInputSize) { + inputSize += BigInt(chunk.length); + if (inputSize > limit) { callback( new Error( - `Encryption input exceeds maxInputSize (${maxInputSize} bytes). Use AES-256-CTR for large data.`, + `Encryption input exceeds maxInputSize (${effectiveMaxInput} bytes). Use AES-256-CTR for large data.`, ), ); return; @@ -78,7 +112,7 @@ export const encryptStream = ( }; } stream.result = () => ({ - key: "encrypt", + key: resultKey ?? "encrypt", value: { algorithm, iv, @@ -90,53 +124,110 @@ export const encryptStream = ( return stream; }; +// AEAD decrypt: buffer the whole ciphertext and only release plaintext after +// final() verifies the auth tag. Node's Decipher is a streaming Transform that +// emits decrypted plaintext incrementally and checks the tag only at final(); +// using it directly would release UNAUTHENTICATED plaintext to downstream +// consumers before the tag is ever verified. Buffering-then-verifying matches +// the web implementation's authenticated-before-release guarantee. +const aeadDecryptStream = ( + { cipherName, key, iv, authTag, aad, maxInputSize, maxOutputSize }, + streamOptions, +) => { + // The decipher here is driven manually via update()/final() and is never + // exposed as a stream, so forwarding streamOptions to it has no effect. The + // default auth-tag length is already 16 bytes for every AEAD cipher we use, + // so no options object is needed. + const decipher = createDecipheriv(cipherName, key, iv); + decipher.setAuthTag(authTag); + if (aad != null) { + decipher.setAAD(aad); + } + maxInputSize ??= DEFAULT_MAX_INPUT_SIZE; + const chunks = []; + let inputSize = 0; + const transform = (chunk) => { + // Bound memory before buffering/verification: we must buffer the whole + // ciphertext to verify the tag before releasing plaintext, so cap input. + inputSize += chunk.length; + if (inputSize > maxInputSize) { + throw new Error( + `Decryption input exceeds maxInputSize (${maxInputSize} bytes)`, + ); + } + chunks.push(chunk); + }; + const flush = (enqueue) => { + const ciphertext = Buffer.concat(chunks); + // update() may produce plaintext, but we withhold it until final() + // succeeds; if the tag is wrong, final() throws and nothing is emitted. + const head = decipher.update(ciphertext); + const tail = decipher.final(); + const plaintext = Buffer.concat([head, tail]); + if (maxOutputSize != null && plaintext.length > maxOutputSize) { + throw new Error( + `Decryption output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ); + } + enqueue(plaintext); + }; + return createTransformStream(transform, flush, streamOptions); +}; + export const decryptStream = ( - { algorithm = "AES-256-GCM", key, iv, authTag, aad, maxOutputSize } = {}, + { + algorithm = "AES-256-GCM", + key, + iv, + authTag, + aad, + maxInputSize, + maxOutputSize, + } = {}, streamOptions = {}, ) => { const config = algorithmMap[algorithm]; if (!config) { throw new Error(`Unsupported algorithm: ${algorithm}`); } - const { cipher: cipherName, ivSize } = config; - validateKey(key); + const { cipher: cipherName, ivSize, keySize } = config; + validateKey(key, keySize); validateIv(iv, ivSize, algorithm); - validateAad(aad); - const authTagLength = authAlgorithms.includes(algorithm) ? 16 : undefined; + validateAad(aad, algorithm); if (authAlgorithms.includes(algorithm)) { validateAuthTag(authTag, algorithm); + return aeadDecryptStream( + { cipherName, key, iv, authTag, aad, maxInputSize, maxOutputSize }, + streamOptions, + ); } - const stream = createDecipheriv(cipherName, key, iv, { - ...streamOptions, - authTagLength, - }); - if (authTag) { - stream.setAuthTag(authTag); - } - if (aad && authAlgorithms.includes(algorithm)) { - stream.setAAD(aad); - } + // AES-256-CTR: unauthenticated, safe to stream incrementally. + const stream = createDecipheriv(cipherName, key, iv, streamOptions); + // Only intercept push when an output ceiling is configured; with no ceiling + // the decipher is returned untouched (no per-chunk accounting overhead and no + // override to leak). Installing the override only when needed also means the + // inner accounting never has to re-check `maxOutputSize`. if (maxOutputSize != null) { let outputSize = 0; const originalPush = stream.push.bind(stream); stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Decryption output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } + // EOF marker (null) carries no bytes and must always pass through. + if (chunk === null) return originalPush(chunk); + outputSize += chunk.length; + if (outputSize > maxOutputSize) { + // Tear the stream down with the limit error and withhold this chunk. + // The push() return value is irrelevant here: a destroyed stream emits + // nothing further, so we simply return (undefined) without forwarding + // the chunk to originalPush. + stream.destroy( + new Error( + `Decryption output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ), + ); + return; } return originalPush(chunk); }; - stream.on("close", () => { - stream.push = originalPush; - }); } return stream; }; diff --git a/packages/encrypt/index.test.js b/packages/encrypt/index.test.js index db282e0..dd53c35 100644 --- a/packages/encrypt/index.test.js +++ b/packages/encrypt/index.test.js @@ -1,6 +1,6 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT -import { deepStrictEqual, strictEqual, throws } from "node:assert"; +import { deepStrictEqual, rejects, strictEqual, throws } from "node:assert"; import { randomBytes } from "node:crypto"; import test from "node:test"; @@ -204,6 +204,110 @@ test(`${variant}: decryptStream should fail with wrong key`, async (_t) => { } }); +// *** AEAD: no unauthenticated plaintext released on tamper *** // +const tamperedAeadEmitsNothing = async (algorithm) => { + // Large enough that a streaming Decipher would emit plaintext mid-stream + // before final() ever checks the auth tag. + const input = "a".repeat(64 * 1024); + const enc = await encryptStream({ key, algorithm }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv, authTag } = enc.result().value; + + // Tamper: flip the last byte of the ciphertext. + const ciphertext = Buffer.concat(encryptedChunks.map((c) => Buffer.from(c))); + ciphertext[ciphertext.length - 1] ^= 0xff; + // Re-chunk so a streaming decipher would have multiple transform calls. + const tamperedChunks = []; + for (let i = 0; i < ciphertext.length; i += 4096) { + tamperedChunks.push(ciphertext.subarray(i, i + 4096)); + } + + const dec = await decryptStream({ key, iv, authTag, algorithm }); + const emitted = []; + let threw = false; + try { + const decStream = createReadableStream(tamperedChunks).pipe(dec); + for await (const chunk of decStream) { + emitted.push(chunk); + } + } catch (_e) { + threw = true; + } + // Must error AND must NOT have released any plaintext before verification. + strictEqual(threw, true); + strictEqual( + emitted.reduce((n, c) => n + c.length, 0), + 0, + ); +}; + +test(`${variant}: AES-256-GCM decrypt releases NO plaintext on tampered ciphertext`, async (_t) => { + await tamperedAeadEmitsNothing("AES-256-GCM"); +}); + +test(`${variant}: CHACHA20-POLY1305 decrypt releases NO plaintext on tampered ciphertext`, async (_t) => { + await tamperedAeadEmitsNothing("CHACHA20-POLY1305"); +}); + +test(`${variant}: AES-256-GCM decrypt enforces maxInputSize`, async (_t) => { + const input = "a".repeat(1000); + const enc = await encryptStream({ key }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv, authTag } = enc.result().value; + const ciphertext = Buffer.concat(encryptedChunks.map((c) => Buffer.from(c))); + const chunked = []; + for (let i = 0; i < ciphertext.length; i += 100) { + chunked.push(ciphertext.subarray(i, i + 100)); + } + const dec = await decryptStream({ key, iv, authTag, maxInputSize: 100 }); + let threw = false; + let message = ""; + try { + const decStream = createReadableStream(chunked).pipe(dec); + for await (const _chunk of decStream) { + // should fail + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxInputSize"), true); +}); + +test(`${variant}: AES-256-GCM decrypt enforces maxOutputSize`, async (_t) => { + const input = "a".repeat(1000); + const enc = await encryptStream({ key }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv, authTag } = enc.result().value; + const dec = await decryptStream({ key, iv, authTag, maxOutputSize: 100 }); + let threw = false; + let message = ""; + try { + const decStream = createReadableStream(encryptedChunks).pipe(dec); + for await (const _chunk of decStream) { + // should fail + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxOutputSize"), true); +}); + test(`${variant}: decryptStream maxOutputSize should limit output`, async (_t) => { const input = "a".repeat(1000); const enc = encryptStream({ key, algorithm: "AES-256-CTR" }); @@ -338,6 +442,14 @@ test(`${variant}: decryptStream should throw for invalid IV length`, (_t) => { ); }); +test(`${variant}: decryptStream should report 0 for missing IV`, (_t) => { + throws(() => decryptStream({ key, authTag: randomBytes(16) }), /got 0/); +}); + +test(`${variant}: decryptStream should report 0 for missing authTag`, (_t) => { + throws(() => decryptStream({ key, iv: randomBytes(12) }), /got 0/); +}); + test(`${variant}: decryptStream should throw for missing authTag on GCM`, (_t) => { throws( () => decryptStream({ key, iv: randomBytes(12) }), @@ -385,3 +497,903 @@ test(`${variant}: default export should include all functions`, (_t) => { "generateEncryptionKey", ]); }); + +// *** Web source exercised directly *** // +// Node's export resolution always picks index.node.mjs (the "node" condition +// matches first under every --conditions value), so the web implementation can +// only be exercised via a direct file import. crypto.subtle, CompressionStream, +// TextEncoder, and the WHATWG stream globals are all available in Node 24. +const importWeb = () => + import(`file://${new URL("./index.web.js", import.meta.url).pathname}`); + +const webRoundtrip = async (algorithm, aad) => { + const web = await importWeb(); + const input = "the web implementation must round-trip correctly"; + const enc = await web.encryptStream({ key, algorithm, aad }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv, authTag } = enc.result().value; + const dec = await web.decryptStream({ key, iv, authTag, algorithm, aad }); + const decryptedChunks = []; + const decStream = createReadableStream(encryptedChunks).pipe(dec); + for await (const chunk of decStream) { + decryptedChunks.push(chunk); + } + strictEqual(Buffer.concat(decryptedChunks).toString("utf8"), input); +}; + +test(`${variant}: web roundtrip AES-256-GCM`, async (_t) => { + await webRoundtrip("AES-256-GCM"); +}); + +test(`${variant}: web roundtrip AES-256-GCM with AAD`, async (_t) => { + await webRoundtrip("AES-256-GCM", Buffer.from("metadata")); +}); + +test(`${variant}: web roundtrip AES-256-CTR`, async (_t) => { + await webRoundtrip("AES-256-CTR"); +}); + +test(`${variant}: web roundtrip CHACHA20-POLY1305`, async (_t) => { + await webRoundtrip("CHACHA20-POLY1305"); +}); + +test(`${variant}: web generateEncryptionKey default and 128-bit`, async (_t) => { + const web = await importWeb(); + strictEqual(web.generateEncryptionKey().byteLength, 32); + strictEqual(web.generateEncryptionKey({ bits: 128 }).byteLength, 16); +}); + +test(`${variant}: web generateEncryptionKey throws for unsupported bits`, async (_t) => { + const web = await importWeb(); + throws( + () => web.generateEncryptionKey({ bits: 512 }), + /Unsupported key size/, + ); +}); + +test(`${variant}: web default export includes all functions`, async (_t) => { + const web = await importWeb(); + deepStrictEqual(Object.keys(web.default).sort(), [ + "decryptStream", + "encryptStream", + "generateEncryptionKey", + ]); +}); + +test(`${variant}: web encryptStream throws for invalid key`, async (_t) => { + const web = await importWeb(); + await rejects(() => web.encryptStream({ key: randomBytes(16) }), /32 bytes/); +}); + +test(`${variant}: web encryptStream reports 0 for missing key`, async (_t) => { + const web = await importWeb(); + await rejects(() => web.encryptStream({}), /got 0/); +}); + +test(`${variant}: web encryptStream throws for invalid IV`, async (_t) => { + const web = await importWeb(); + await rejects( + () => web.encryptStream({ key, iv: randomBytes(8) }), + /IV for AES-256-GCM/, + ); +}); + +test(`${variant}: web decryptStream throws for missing authTag`, async (_t) => { + const web = await importWeb(); + await rejects( + () => web.decryptStream({ key, iv: randomBytes(12) }), + /authTag for AES-256-GCM/, + ); +}); + +test(`${variant}: web encryptStream throws for unsupported algorithm`, async (_t) => { + const web = await importWeb(); + await rejects( + () => web.encryptStream({ key, algorithm: "INVALID" }), + /Unsupported algorithm/, + ); +}); + +test(`${variant}: web decryptStream throws for unsupported algorithm`, async (_t) => { + const web = await importWeb(); + await rejects( + () => + web.decryptStream({ + key, + iv: randomBytes(12), + authTag: randomBytes(16), + algorithm: "INVALID", + }), + /Unsupported algorithm/, + ); +}); + +const webEncryptMaxInput = async (algorithm) => { + const web = await importWeb(); + const enc = await web.encryptStream({ key, algorithm, maxInputSize: 100 }); + let threw = false; + let message = ""; + try { + const stream = createReadableStream("a".repeat(200)).pipe(enc); + for await (const _chunk of stream) { + // should fail + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxInputSize"), true); +}; + +test(`${variant}: web AES-256-GCM encrypt enforces maxInputSize`, async (_t) => { + await webEncryptMaxInput("AES-256-GCM"); +}); + +test(`${variant}: web CHACHA20-POLY1305 encrypt enforces maxInputSize`, async (_t) => { + await webEncryptMaxInput("CHACHA20-POLY1305"); +}); + +const webDecryptMaxOutput = async (algorithm) => { + const web = await importWeb(); + const input = "a".repeat(1000); + const enc = await web.encryptStream({ key, algorithm }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv, authTag } = enc.result().value; + const dec = await web.decryptStream({ + key, + iv, + authTag, + algorithm, + maxOutputSize: 100, + }); + let threw = false; + let message = ""; + try { + const decStream = createReadableStream(encryptedChunks).pipe(dec); + for await (const _chunk of decStream) { + // should fail + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxOutputSize"), true); +}; + +test(`${variant}: web AES-256-GCM decrypt enforces maxOutputSize`, async (_t) => { + await webDecryptMaxOutput("AES-256-GCM"); +}); + +test(`${variant}: web AES-256-CTR decrypt enforces maxOutputSize`, async (_t) => { + await webDecryptMaxOutput("AES-256-CTR"); +}); + +test(`${variant}: web CHACHA20-POLY1305 decrypt enforces maxOutputSize`, async (_t) => { + await webDecryptMaxOutput("CHACHA20-POLY1305"); +}); + +test(`${variant}: web AES-256-CTR multi-chunk roundtrip advances counter`, async (_t) => { + const web = await importWeb(); + // Many chunks larger than one AES block so blockOffset advances and the + // incrementCounter carry path runs. + const chunks = Array.from({ length: 8 }, (_, i) => "z".repeat(64) + i); + const enc = await web.encryptStream({ key, algorithm: "AES-256-CTR" }); + const encryptedChunks = []; + const encStream = createReadableStream(chunks).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv } = enc.result().value; + const dec = await web.decryptStream({ key, iv, algorithm: "AES-256-CTR" }); + const decryptedChunks = []; + const decStream = createReadableStream(encryptedChunks).pipe(dec); + for await (const chunk of decStream) { + decryptedChunks.push(chunk); + } + strictEqual(Buffer.concat(decryptedChunks).toString("utf8"), chunks.join("")); +}); + +test(`${variant}: web AES-256-GCM decrypt releases NO plaintext on tamper`, async (_t) => { + const web = await importWeb(); + const input = "a".repeat(64 * 1024); + const enc = await web.encryptStream({ key }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv, authTag } = enc.result().value; + const ciphertext = Buffer.concat(encryptedChunks.map((c) => Buffer.from(c))); + ciphertext[ciphertext.length - 1] ^= 0xff; + + const dec = await web.decryptStream({ key, iv, authTag }); + const emitted = []; + let threw = false; + try { + const decStream = createReadableStream([ciphertext]).pipe(dec); + for await (const chunk of decStream) { + emitted.push(chunk); + } + } catch (_e) { + threw = true; + } + strictEqual(threw, true); + strictEqual( + emitted.reduce((n, c) => n + c.length, 0), + 0, + ); +}); + +test(`${variant}: web AES-256-GCM decrypt enforces maxInputSize before buffering`, async (_t) => { + const web = await importWeb(); + const input = "a".repeat(1000); + const enc = await web.encryptStream({ key }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv, authTag } = enc.result().value; + // Re-chunk so the input-size cap can trip mid-stream during transform. + const ciphertext = Buffer.concat(encryptedChunks.map((c) => Buffer.from(c))); + const chunked = []; + for (let i = 0; i < ciphertext.length; i += 100) { + chunked.push(ciphertext.subarray(i, i + 100)); + } + + const dec = await web.decryptStream({ key, iv, authTag, maxInputSize: 100 }); + let threw = false; + let message = ""; + try { + const decStream = createReadableStream(chunked).pipe(dec); + for await (const _chunk of decStream) { + // should fail before any plaintext + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxInputSize"), true); +}); + +test(`${variant}: web CHACHA20-POLY1305 decrypt enforces maxInputSize`, async (_t) => { + const web = await importWeb(); + const input = "a".repeat(1000); + const enc = await web.encryptStream({ key, algorithm: "CHACHA20-POLY1305" }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(chunk); + } + const { iv, authTag } = enc.result().value; + const ciphertext = Buffer.concat(encryptedChunks.map((c) => Buffer.from(c))); + const chunked = []; + for (let i = 0; i < ciphertext.length; i += 100) { + chunked.push(ciphertext.subarray(i, i + 100)); + } + const dec = await web.decryptStream({ + key, + iv, + authTag, + algorithm: "CHACHA20-POLY1305", + maxInputSize: 100, + }); + let threw = false; + let message = ""; + try { + const decStream = createReadableStream(chunked).pipe(dec); + for await (const _chunk of decStream) { + // should fail + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxInputSize"), true); +}); + +test(`${variant}: web rejects aad supplied with non-AEAD AES-256-CTR`, async (_t) => { + const web = await importWeb(); + await rejects( + () => + web.encryptStream({ + key, + algorithm: "AES-256-CTR", + iv: randomBytes(16), + aad: Buffer.from("x"), + }), + /aad is not supported/, + ); +}); + +test(`${variant}: node rejects aad supplied with non-AEAD AES-256-CTR`, (_t) => { + throws( + () => + encryptStream({ + key, + algorithm: "AES-256-CTR", + iv: randomBytes(16), + aad: Buffer.from("x"), + }), + /aad is not supported/, + ); +}); + +test(`${variant}: AES-256-CTR encrypt honors explicit maxInputSize`, async (_t) => { + const input = "a".repeat(200); + const enc = await encryptStream({ + key, + algorithm: "AES-256-CTR", + maxInputSize: 100, + }); + let threw = false; + let message = ""; + try { + await pipeline([createReadableStream(input), enc]); + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxInputSize"), true); +}); + +test(`${variant}: web validateAad rejects ArrayBuffer like node (type parity)`, async (_t) => { + const web = await importWeb(); + await rejects( + () => web.encryptStream({ key, aad: new ArrayBuffer(8) }), + /aad must be/, + ); +}); + +// *** AES-256-CTR counter-wrap determinism & node/web parity *** // +// IV whose low 64 bits are set so the per-block counter wraps across the +// 2^64 boundary within a few blocks. With length:64 the web build wraps only +// the low half while incrementCounter advances the full 128-bit IV, so +// (a) one-chunk vs split-chunk web ciphertext diverges past the wrap and +// (b) node and web ciphertext diverge. length:128 fixes both. +const ctrWrapIv = () => { + const iv = new Uint8Array(16); + // high 64 bits = 0, low 64 bits = 0xFFFFFFFFFFFFFFFE + for (let i = 8; i < 16; i++) { + iv[i] = 0xff; + } + iv[15] = 0xfe; + return iv; +}; + +const ctrEncryptChunks = async (mod, iv, chunks) => { + const enc = await mod.encryptStream({ key, algorithm: "AES-256-CTR", iv }); + const out = []; + const encStream = createReadableStream(chunks).pipe(enc); + for await (const chunk of encStream) { + out.push(Buffer.from(chunk)); + } + return Buffer.concat(out); +}; + +test(`${variant}: web AES-256-CTR one-chunk == split-chunk across counter wrap`, async (_t) => { + const web = await importWeb(); + const iv = ctrWrapIv(); + // 4 blocks of data so the counter advances across the 2^64 low-half wrap. + const plaintext = Buffer.alloc(64, 0x41); + const oneChunk = await ctrEncryptChunks(web, new Uint8Array(iv), [plaintext]); + const splitChunks = []; + for (let i = 0; i < plaintext.length; i += 16) { + splitChunks.push(plaintext.subarray(i, i + 16)); + } + const split = await ctrEncryptChunks(web, new Uint8Array(iv), splitChunks); + deepStrictEqual(oneChunk, split); +}); + +test(`${variant}: web AES-256-CTR encrypt-then-rechunk-decrypt round-trips across wrap`, async (_t) => { + const web = await importWeb(); + const iv = ctrWrapIv(); + const plaintext = Buffer.alloc(64, 0x42); + const ciphertext = await ctrEncryptChunks(web, new Uint8Array(iv), [ + plaintext, + ]); + // Re-chunk the ciphertext differently from how it was produced. + const rechunked = []; + for (let i = 0; i < ciphertext.length; i += 16) { + rechunked.push(ciphertext.subarray(i, i + 16)); + } + const dec = await web.decryptStream({ + key, + iv: new Uint8Array(iv), + algorithm: "AES-256-CTR", + }); + const out = []; + const decStream = createReadableStream(rechunked).pipe(dec); + for await (const chunk of decStream) { + out.push(Buffer.from(chunk)); + } + deepStrictEqual(Buffer.concat(out), plaintext); +}); + +test(`${variant}: node and web AES-256-CTR produce identical ciphertext across counter wrap`, async (_t) => { + const web = await importWeb(); + const iv = ctrWrapIv(); + const plaintext = Buffer.alloc(64, 0x43); + const nodeCipher = await ctrEncryptChunks( + { encryptStream }, + Buffer.from(iv), + [plaintext], + ); + const webCipher = await ctrEncryptChunks(web, new Uint8Array(iv), [ + plaintext, + ]); + deepStrictEqual(webCipher, nodeCipher); +}); + +// *** Web AES-256-CTR encrypt maxInputSize parity *** // +test(`${variant}: web AES-256-CTR encrypt honors explicit maxInputSize`, async (_t) => { + const web = await importWeb(); + const enc = await web.encryptStream({ + key, + algorithm: "AES-256-CTR", + iv: randomBytes(16), + maxInputSize: 100, + }); + let threw = false; + let message = ""; + try { + const stream = createReadableStream("a".repeat(200)).pipe(enc); + for await (const _chunk of stream) { + // should fail + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxInputSize"), true); +}); + +// *** resultKey honored in result() (node + web, all encrypt modes) *** // +const resultKeyHonored = async (mod, algorithm) => { + const enc = await mod.encryptStream({ key, algorithm, resultKey: "cipher" }); + const encStream = createReadableStream("resultKey payload").pipe(enc); + for await (const _chunk of encStream) { + // drain + } + const { key: resultKey, value } = enc.result(); + strictEqual(resultKey, "cipher"); + strictEqual(value.algorithm, algorithm); +}; + +test(`${variant}: node encryptStream result() honors resultKey (AES-256-GCM)`, async (_t) => { + await resultKeyHonored({ encryptStream }, "AES-256-GCM"); +}); + +test(`${variant}: node encryptStream result() honors resultKey (AES-256-CTR)`, async (_t) => { + await resultKeyHonored({ encryptStream }, "AES-256-CTR"); +}); + +test(`${variant}: node encryptStream result() honors resultKey (CHACHA20-POLY1305)`, async (_t) => { + await resultKeyHonored({ encryptStream }, "CHACHA20-POLY1305"); +}); + +test(`${variant}: web encryptStream result() honors resultKey (AES-256-GCM)`, async (_t) => { + const web = await importWeb(); + await resultKeyHonored(web, "AES-256-GCM"); +}); + +test(`${variant}: web encryptStream result() honors resultKey (AES-256-CTR)`, async (_t) => { + const web = await importWeb(); + await resultKeyHonored(web, "AES-256-CTR"); +}); + +test(`${variant}: web encryptStream result() honors resultKey (CHACHA20-POLY1305)`, async (_t) => { + const web = await importWeb(); + await resultKeyHonored(web, "CHACHA20-POLY1305"); +}); + +test(`${variant}: encryptStream result() defaults resultKey to 'encrypt'`, async (_t) => { + const enc = encryptStream({ key }); + await pipeline([createReadableStream("default key"), enc]); + strictEqual(enc.result().key, "encrypt"); +}); + +// *** generateEncryptionKey({bits:128}) must produce a usable key *** // +const key128Usable = async (mod, algorithm) => { + const k = mod.generateEncryptionKey({ bits: 128 }); + strictEqual(k.byteLength, 16); + const input = "128-bit key round-trip"; + const enc = await mod.encryptStream({ key: k, algorithm }); + const encryptedChunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + encryptedChunks.push(Buffer.from(chunk)); + } + const { iv, authTag } = enc.result().value; + const dec = await mod.decryptStream({ key: k, iv, authTag, algorithm }); + const decryptedChunks = []; + const decStream = createReadableStream(encryptedChunks).pipe(dec); + for await (const chunk of decStream) { + decryptedChunks.push(Buffer.from(chunk)); + } + strictEqual(Buffer.concat(decryptedChunks).toString("utf8"), input); +}; + +test(`${variant}: node 128-bit key usable with AES-128-GCM`, async (_t) => { + await key128Usable( + { encryptStream, decryptStream, generateEncryptionKey }, + "AES-128-GCM", + ); +}); + +test(`${variant}: node 128-bit key usable with AES-128-CTR`, async (_t) => { + await key128Usable( + { encryptStream, decryptStream, generateEncryptionKey }, + "AES-128-CTR", + ); +}); + +test(`${variant}: web 128-bit key usable with AES-128-GCM`, async (_t) => { + const web = await importWeb(); + await key128Usable(web, "AES-128-GCM"); +}); + +test(`${variant}: web 128-bit key usable with AES-128-CTR`, async (_t) => { + const web = await importWeb(); + await key128Usable(web, "AES-128-CTR"); +}); + +// *** Mutation-kill: exact validation error messages *** // +test(`${variant}: validateKey emits exact bits/got in 256-bit message`, (_t) => { + throws( + () => encryptStream({ key: randomBytes(16) }), + /Encryption key must be 32 bytes \(256 bits\), got 16/, + ); +}); + +test(`${variant}: validateKey emits exact bits/got in 128-bit message`, (_t) => { + throws( + () => encryptStream({ key: randomBytes(8), algorithm: "AES-128-GCM" }), + /Encryption key must be 16 bytes \(128 bits\), got 8/, + ); +}); + +test(`${variant}: validateKey reports "got 0" (not undefined) for missing key`, (_t) => { + // Kills `key?.length ?? 0` -> `key?.length && 0` (would yield "got undefined"). + throws( + () => encryptStream({}), + /Encryption key must be 32 bytes \(256 bits\), got 0/, + ); +}); + +test(`${variant}: validateAuthTag rejects 15-byte authTag with exact message`, (_t) => { + // Kills `authTag.length !== 16` -> `false`; a non-falsy wrong-length tag + // must still be rejected with the validation message (not a deeper error). + throws( + () => decryptStream({ key, iv: randomBytes(12), authTag: randomBytes(15) }), + /authTag for AES-256-GCM must be 16 bytes, got 15/, + ); +}); + +// *** Mutation-kill: encrypt maxInputSize message + boundary *** // +test(`${variant}: encrypt maxInputSize message reports the explicit limit`, async (_t) => { + // Kills `maxInputSize ?? DEFAULT` -> `maxInputSize && DEFAULT` (reportedLimit + // would become 67108864 instead of 100). + const enc = encryptStream({ key, maxInputSize: 100 }); + let message = ""; + try { + await pipeline([createReadableStream("a".repeat(200)), enc]); + } catch (e) { + message = e.message; + } + strictEqual(message.includes("(100 bytes)"), true); +}); + +test(`${variant}: encrypt maxInputSize boundary: exactly the limit succeeds`, async (_t) => { + // Kills `inputSize > limit` -> `inputSize >= limit` on the encrypt path. + const enc = encryptStream({ key, maxInputSize: 100 }); + await pipeline([createReadableStream("a".repeat(100)), enc]); + strictEqual(enc.result().value.algorithm, "AES-256-GCM"); +}); + +test(`${variant}: encrypt maxInputSize boundary: one over the limit fails`, async (_t) => { + const enc = encryptStream({ key, maxInputSize: 100 }); + let threw = false; + try { + await pipeline([createReadableStream("a".repeat(101)), enc]); + } catch (_e) { + threw = true; + } + strictEqual(threw, true); +}); + +// *** Mutation-kill: encrypt default ceilings (CTR huge vs AEAD 64MB) *** // +// AES-CTR with no maxInputSize uses the keystream-reuse ceiling (2^68), so a +// >64MB stream succeeds; AEAD modes default to the 64MB DoS guard, so >64MB is +// rejected with the maxInputSize message. 65MB distinguishes the two ceilings. +// Drain without retaining ciphertext to keep peak memory bounded. +const drain = async (stream) => { + for await (const _chunk of stream) { + // discard + } +}; +const big65 = () => Buffer.alloc(65 * 1024 * 1024, 0x41); + +test(`${variant}: AES-256-CTR encrypt allows >64MB with no maxInputSize`, async (_t) => { + // Kills ctrAlgorithms mutations and `usingCtrDefaultCeiling = false`: any of + // those drops AES-256-CTR back to the 64MB default and would reject this. + const enc = encryptStream({ key, algorithm: "AES-256-CTR" }); + await drain(createReadableStream([big65()]).pipe(enc)); + strictEqual(enc.result().value.algorithm, "AES-256-CTR"); +}); + +test(`${variant}: AES-128-CTR encrypt allows >64MB with no maxInputSize`, async (_t) => { + // Kills the "AES-128-CTR" element of ctrAlgorithms (empty/blanked array). + const k16 = randomBytes(16); + const enc = encryptStream({ key: k16, algorithm: "AES-128-CTR" }); + await drain(createReadableStream([big65()]).pipe(enc)); + strictEqual(enc.result().value.algorithm, "AES-128-CTR"); +}); + +test(`${variant}: AES-256-GCM encrypt rejects >64MB at the 64MB default`, async (_t) => { + // Kills `if (maxInputSize != null) -> true`, `else if (...) -> true`, removal + // of the DEFAULT_MAX_INPUT_SIZE assignment: each would let >64MB AEAD pass. + const enc = encryptStream({ key }); + let threw = false; + let message = ""; + try { + await drain(createReadableStream([big65()]).pipe(enc)); + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxInputSize"), true); + strictEqual(message.includes("67108864"), true); +}); + +// *** Mutation-kill: AEAD decrypt maxInputSize / maxOutputSize boundaries *** // +const gcmCiphertext = async (input) => { + const enc = encryptStream({ key }); + const chunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + chunks.push(Buffer.from(chunk)); + } + const { iv, authTag } = enc.result().value; + return { ciphertext: Buffer.concat(chunks), iv, authTag }; +}; + +test(`${variant}: AEAD decrypt maxInputSize boundary: exactly the limit succeeds`, async (_t) => { + // Kills `inputSize > maxInputSize` -> `>=` in aeadDecryptStream. + const { ciphertext, iv, authTag } = await gcmCiphertext("boundary in"); + const dec = decryptStream({ + key, + iv, + authTag, + maxInputSize: ciphertext.length, + }); + const out = []; + const decStream = createReadableStream([ciphertext]).pipe(dec); + for await (const chunk of decStream) { + out.push(Buffer.from(chunk)); + } + strictEqual(Buffer.concat(out).toString("utf8"), "boundary in"); +}); + +test(`${variant}: AEAD decrypt maxInputSize boundary: one over the limit fails`, async (_t) => { + const { ciphertext, iv, authTag } = await gcmCiphertext("boundary in"); + const dec = decryptStream({ + key, + iv, + authTag, + maxInputSize: ciphertext.length - 1, + }); + let threw = false; + try { + const decStream = createReadableStream([ciphertext]).pipe(dec); + for await (const _chunk of decStream) { + // should fail + } + } catch (_e) { + threw = true; + } + strictEqual(threw, true); +}); + +test(`${variant}: AEAD decrypt within maxOutputSize succeeds (no false error)`, async (_t) => { + // Kills `maxOutputSize != null && X` -> `|| X` and `&& true`: those would + // reject a plaintext that is within the configured limit. + const input = "exact output"; + const { ciphertext, iv, authTag } = await gcmCiphertext(input); + const dec = decryptStream({ + key, + iv, + authTag, + maxOutputSize: input.length, + }); + const out = []; + const decStream = createReadableStream([ciphertext]).pipe(dec); + for await (const chunk of decStream) { + out.push(Buffer.from(chunk)); + } + strictEqual(Buffer.concat(out).toString("utf8"), input); +}); + +test(`${variant}: AEAD decrypt maxOutputSize boundary: one under the limit fails`, async (_t) => { + // Kills `plaintext.length > maxOutputSize` -> `>=` in aeadDecryptStream. + const input = "exact output"; + const { ciphertext, iv, authTag } = await gcmCiphertext(input); + const dec = decryptStream({ + key, + iv, + authTag, + maxOutputSize: input.length - 1, + }); + let threw = false; + let message = ""; + try { + const decStream = createReadableStream([ciphertext]).pipe(dec); + for await (const _chunk of decStream) { + // should fail + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxOutputSize"), true); +}); + +// *** Mutation-kill: AES-256-CTR decrypt maxOutputSize push override *** // +const ctrCiphertext = async (input) => { + const enc = encryptStream({ key, algorithm: "AES-256-CTR" }); + const chunks = []; + const encStream = createReadableStream(input).pipe(enc); + for await (const chunk of encStream) { + chunks.push(Buffer.from(chunk)); + } + const { iv } = enc.result().value; + return { chunks, iv }; +}; + +test(`${variant}: CTR decrypt maxOutputSize over-limit fails with message`, async (_t) => { + // Kills `if (chunk !== null) -> false`, `if (maxOutputSize != null) -> false`, + // `outputSize += -> -=`, and `outputSize > maxOutputSize -> false`: each would + // suppress the over-limit error. + const { chunks, iv } = await ctrCiphertext("a".repeat(500)); + const dec = decryptStream({ + key, + iv, + algorithm: "AES-256-CTR", + maxOutputSize: 100, + }); + let threw = false; + let message = ""; + let emitted = 0; + try { + const decStream = createReadableStream(chunks).pipe(dec); + for await (const chunk of decStream) { + emitted += chunk.length; + } + } catch (e) { + threw = true; + message = e.message; + } + strictEqual(threw, true); + strictEqual(message.includes("maxOutputSize"), true); + strictEqual(message.includes("100"), true); + strictEqual(emitted <= 100, true); +}); + +test(`${variant}: CTR decrypt maxOutputSize boundary: exactly the limit succeeds`, async (_t) => { + // Kills `outputSize > maxOutputSize` -> `>=` on the CTR decrypt path: + // output length equal to the limit must NOT error. + const input = "a".repeat(100); + const { chunks, iv } = await ctrCiphertext(input); + const dec = decryptStream({ + key, + iv, + algorithm: "AES-256-CTR", + maxOutputSize: 100, + }); + const out = []; + const decStream = createReadableStream(chunks).pipe(dec); + for await (const chunk of decStream) { + out.push(Buffer.from(chunk)); + } + strictEqual(Buffer.concat(out).toString("utf8"), input); +}); + +// *** Mutation-kill: createCipheriv options object forwards streamOptions *** +// The 4th argument `{ ...streamOptions, authTagLength }` is the cipher's +// Transform options. Replacing it with `{}` (ObjectLiteral mutant) would drop +// the forwarded highWaterMark, so the returned cipher stream would fall back to +// the default highWaterMark instead of the caller-supplied one. +test(`${variant}: encryptStream forwards streamOptions.highWaterMark to the cipher stream`, (_t) => { + const enc = encryptStream({ key }, { highWaterMark: 7 }); + strictEqual( + enc.readableHighWaterMark, + 7, + "highWaterMark from streamOptions must reach createCipheriv", + ); + strictEqual(enc.writableHighWaterMark, 7); +}); + +test(`${variant}: encryptStream cipher uses the default highWaterMark when none supplied`, (_t) => { + // Guards the above: the 7 above is genuinely from streamOptions, not a default. + const enc = encryptStream({ key }); + strictEqual(enc.readableHighWaterMark !== 7, true); +}); + +// *** Mutation-kill: maxOutputSize == null means NO ceiling (explicit null) *** +// `maxOutputSize != null && ...` must short-circuit to false when null. The +// `true && ...` mutant degrades to `length > null` (=> length > 0) and would +// wrongly reject any non-empty plaintext. +test(`${variant}: AEAD decrypt with maxOutputSize: null imposes no output ceiling`, async (_t) => { + const input = "no ceiling please"; + const { ciphertext, iv, authTag } = await gcmCiphertext(input); + const dec = decryptStream({ key, iv, authTag, maxOutputSize: null }); + const out = []; + const decStream = createReadableStream([ciphertext]).pipe(dec); + for await (const chunk of decStream) { + out.push(Buffer.from(chunk)); + } + strictEqual(Buffer.concat(out).toString("utf8"), input); +}); + +test(`${variant}: CTR decrypt with maxOutputSize: null imposes no output ceiling`, async (_t) => { + // Kills `if (maxOutputSize != null) -> if (true)` on the CTR push override: + // with null and the mutant, `outputSize > null` (=> > 0) would destroy the + // stream on the first non-empty chunk. + const input = "a".repeat(500); + const { chunks, iv } = await ctrCiphertext(input); + const dec = decryptStream({ + key, + iv, + algorithm: "AES-256-CTR", + maxOutputSize: null, + }); + const out = []; + const decStream = createReadableStream(chunks).pipe(dec); + for await (const chunk of decStream) { + out.push(Buffer.from(chunk)); + } + strictEqual(Buffer.concat(out).toString("utf8"), input); +}); + +test(`${variant}: CTR decrypt maxOutputSize accumulates across chunks`, async (_t) => { + // Reinforces the `outputSize += chunk.length` accumulation (kills `-=`): + // many small chunks whose sum exceeds the limit must error. + const { chunks, iv } = await ctrCiphertext("a".repeat(64)); + const rechunked = []; + const ct = Buffer.concat(chunks); + for (let i = 0; i < ct.length; i += 8) { + rechunked.push(ct.subarray(i, i + 8)); + } + const dec = decryptStream({ + key, + iv, + algorithm: "AES-256-CTR", + maxOutputSize: 32, + }); + let threw = false; + try { + const decStream = createReadableStream(rechunked).pipe(dec); + for await (const _chunk of decStream) { + // should fail once cumulative output passes 32 + } + } catch (_e) { + threw = true; + } + strictEqual(threw, true); +}); diff --git a/packages/encrypt/index.web.js b/packages/encrypt/index.web.js index 9efa1dd..54b8b3c 100644 --- a/packages/encrypt/index.web.js +++ b/packages/encrypt/index.web.js @@ -3,18 +3,30 @@ /* global crypto */ import { createTransformStream } from "@datastream/core"; +const textEncoder = new TextEncoder(); + const DEFAULT_MAX_INPUT_SIZE = 64 * 1024 * 1024; // 64MB const expectedIvSize = { + "AES-128-GCM": 12, "AES-256-GCM": 12, + "AES-128-CTR": 16, "AES-256-CTR": 16, "CHACHA20-POLY1305": 12, }; -const validateKey = (key) => { - if (!key || key.byteLength !== 32) { +const expectedKeySize = { + "AES-128-GCM": 16, + "AES-256-GCM": 32, + "AES-128-CTR": 16, + "AES-256-CTR": 32, + "CHACHA20-POLY1305": 32, +}; + +const validateKey = (key, keySize = 32) => { + if (!key || key.byteLength !== keySize) { throw new Error( - `Encryption key must be 32 bytes (256 bits), got ${key?.byteLength ?? 0}`, + `Encryption key must be ${keySize} bytes (${keySize * 8} bits), got ${key?.byteLength ?? 0}`, ); } }; @@ -29,28 +41,35 @@ const validateIv = (iv, algorithm) => { }; const validateAuthTag = (authTag, algorithm) => { - if (!authTag || authTag.byteLength !== 16) { + if (authTag?.byteLength !== 16) { throw new Error( `authTag for ${algorithm} must be 16 bytes, got ${authTag?.byteLength ?? 0}`, ); } }; -const validateAad = (aad) => { - if ( - aad != null && - !(aad instanceof Uint8Array) && - !(aad instanceof ArrayBuffer) - ) { - throw new Error("aad must be a Uint8Array or ArrayBuffer"); +const authAlgorithms = ["AES-128-GCM", "AES-256-GCM", "CHACHA20-POLY1305"]; + +const validateAad = (aad, algorithm) => { + // Match the node implementation's accepted types so identical inputs behave + // identically on both platforms (Buffer is a Uint8Array; ArrayBuffer is not + // accepted). + if (aad != null && !(aad instanceof Uint8Array)) { + throw new Error("aad must be a Uint8Array"); + } + // AAD only has meaning for authenticated modes. Silently dropping it for + // AES-256-CTR would give the caller a false sense of integrity binding. + if (aad != null && !authAlgorithms.includes(algorithm)) { + throw new Error( + `aad is not supported for ${algorithm} (not authenticated)`, + ); } }; const concatBuffers = (chunks) => { let totalLength = 0; const buffers = chunks.map((chunk) => { - const buf = - chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + const buf = chunk instanceof Uint8Array ? chunk : textEncoder.encode(chunk); totalLength += buf.byteLength; return buf; }); @@ -63,6 +82,22 @@ const concatBuffers = (chunks) => { return result; }; +// libsodium-wrappers is an optional peer dep loaded lazily. After `await +// ready`, the bound functions live on the module's default export, not the +// namespace object, so normalize to that. +const loadSodium = async () => { + let mod; + try { + mod = await import("libsodium-wrappers"); + await mod.ready; + } catch { + throw new Error( + "CHACHA20-POLY1305 requires libsodium-wrappers. Install it: npm install libsodium-wrappers", + ); + } + return mod.default ?? mod; +}; + // Max safe counter: 2^64 blocks = 2^68 bytes (256 EB). // Beyond this, keystream reuse breaks confidentiality. // BigInt so the comparison stays meaningful past 2^53. @@ -85,18 +120,20 @@ const incrementCounter = (iv, blockOffset) => { }; // AES-GCM: buffer all chunks, encrypt on flush -const aesGcmEncrypt = async ({ key, iv, aad, maxInputSize }, streamOptions) => { - validateKey(key); +const aesGcmEncrypt = async ( + { algorithm = "AES-256-GCM", key, iv, aad, maxInputSize, resultKey }, + streamOptions, +) => { + validateKey(key, expectedKeySize[algorithm]); iv ??= crypto.getRandomValues(new Uint8Array(12)); - validateIv(iv, "AES-256-GCM"); - validateAad(aad); + validateIv(iv, algorithm); + validateAad(aad, algorithm); maxInputSize ??= DEFAULT_MAX_INPUT_SIZE; const chunks = []; let inputSize = 0; let authTag; const transform = (chunk) => { - const buf = - chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + const buf = chunk instanceof Uint8Array ? chunk : textEncoder.encode(chunk); inputSize += buf.byteLength; if (inputSize > maxInputSize) { throw new Error( @@ -115,7 +152,7 @@ const aesGcmEncrypt = async ({ key, iv, aad, maxInputSize }, streamOptions) => { ["encrypt"], ); const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv, ...(aad ? { additionalData: aad } : {}) }, + { name: "AES-GCM", iv, ...(aad != null ? { additionalData: aad } : {}) }, cryptoKey, data, ); @@ -126,24 +163,41 @@ const aesGcmEncrypt = async ({ key, iv, aad, maxInputSize }, streamOptions) => { }; const stream = createTransformStream(transform, flush, streamOptions); stream.result = () => ({ - key: "encrypt", - value: { algorithm: "AES-256-GCM", iv, authTag }, + key: resultKey ?? "encrypt", + value: { algorithm, iv, authTag }, }); return stream; }; const aesGcmDecrypt = async ( - { key, iv, authTag, aad, maxOutputSize }, + { + algorithm = "AES-256-GCM", + key, + iv, + authTag, + aad, + maxInputSize, + maxOutputSize, + }, streamOptions, ) => { - validateKey(key); - validateIv(iv, "AES-256-GCM"); - validateAuthTag(authTag, "AES-256-GCM"); - validateAad(aad); + validateKey(key, expectedKeySize[algorithm]); + validateIv(iv, algorithm); + validateAuthTag(authTag, algorithm); + validateAad(aad, algorithm); + maxInputSize ??= DEFAULT_MAX_INPUT_SIZE; const chunks = []; + let inputSize = 0; const transform = (chunk) => { - const buf = - chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + const buf = chunk instanceof Uint8Array ? chunk : textEncoder.encode(chunk); + // Bound memory before buffering/decryption: maxOutputSize alone is checked + // only post-decryption, after the full ciphertext is already allocated. + inputSize += buf.byteLength; + if (inputSize > maxInputSize) { + throw new Error( + `Decryption input exceeds maxInputSize (${maxInputSize} bytes)`, + ); + } chunks.push(buf); }; const flush = async (enqueue) => { @@ -160,7 +214,7 @@ const aesGcmDecrypt = async ( ["decrypt"], ); const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv, ...(aad ? { additionalData: aad } : {}) }, + { name: "AES-GCM", iv, ...(aad != null ? { additionalData: aad } : {}) }, cryptoKey, combined, ); @@ -176,10 +230,15 @@ const aesGcmDecrypt = async ( }; // AES-CTR: true streaming (counter mode is chunk-friendly) -const aesCtrEncrypt = async ({ key, iv }, streamOptions) => { - validateKey(key); +const aesCtrEncrypt = async ( + { algorithm = "AES-256-CTR", key, iv, aad, maxInputSize, resultKey }, + streamOptions, +) => { + validateKey(key, expectedKeySize[algorithm]); + validateAad(aad, algorithm); iv ??= crypto.getRandomValues(new Uint8Array(16)); - validateIv(iv, "AES-256-CTR"); + validateIv(iv, algorithm); + maxInputSize ??= DEFAULT_MAX_INPUT_SIZE; const cryptoKey = await crypto.subtle.importKey( "raw", key, @@ -188,12 +247,21 @@ const aesCtrEncrypt = async ({ key, iv }, streamOptions) => { ["encrypt"], ); let blockOffset = 0n; + let inputSize = 0; const transform = async (chunk, enqueue) => { - const buf = - chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + const buf = chunk instanceof Uint8Array ? chunk : textEncoder.encode(chunk); + inputSize += buf.byteLength; + if (inputSize > maxInputSize) { + throw new Error( + `Encryption input exceeds maxInputSize (${maxInputSize} bytes). Use AES-256-CTR for large data.`, + ); + } const counter = incrementCounter(iv, blockOffset); + // length:128 makes WebCrypto treat the FULL 128-bit block as the counter, + // matching node's aes-256-ctr and the manual incrementCounter, so ciphertext + // is chunking-independent and node<->web identical across the counter wrap. const encrypted = await crypto.subtle.encrypt( - { name: "AES-CTR", counter, length: 64 }, + { name: "AES-CTR", counter, length: 128 }, cryptoKey, buf, ); @@ -202,15 +270,19 @@ const aesCtrEncrypt = async ({ key, iv }, streamOptions) => { }; const stream = createTransformStream(transform, streamOptions); stream.result = () => ({ - key: "encrypt", - value: { algorithm: "AES-256-CTR", iv }, + key: resultKey ?? "encrypt", + value: { algorithm, iv }, }); return stream; }; -const aesCtrDecrypt = async ({ key, iv, maxOutputSize }, streamOptions) => { - validateKey(key); - validateIv(iv, "AES-256-CTR"); +const aesCtrDecrypt = async ( + { algorithm = "AES-256-CTR", key, iv, aad, maxOutputSize }, + streamOptions, +) => { + validateKey(key, expectedKeySize[algorithm]); + validateAad(aad, algorithm); + validateIv(iv, algorithm); const cryptoKey = await crypto.subtle.importKey( "raw", key, @@ -221,11 +293,11 @@ const aesCtrDecrypt = async ({ key, iv, maxOutputSize }, streamOptions) => { let blockOffset = 0n; let outputSize = 0; const transform = async (chunk, enqueue) => { - const buf = - chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + const buf = chunk instanceof Uint8Array ? chunk : textEncoder.encode(chunk); const counter = incrementCounter(iv, blockOffset); + // length:128 — see aesCtrEncrypt; the full 128-bit counter must match. const decrypted = await crypto.subtle.decrypt( - { name: "AES-CTR", counter, length: 64 }, + { name: "AES-CTR", counter, length: 128 }, cryptoKey, buf, ); @@ -246,20 +318,12 @@ const aesCtrDecrypt = async ({ key, iv, maxOutputSize }, streamOptions) => { // ChaCha20-Poly1305: requires optional peer dep const chacha20Encrypt = async ( - { key, iv, aad, maxInputSize }, + { key, iv, aad, maxInputSize, resultKey }, streamOptions, ) => { validateKey(key); - validateAad(aad); - let sodium; - try { - sodium = await import("libsodium-wrappers"); - await sodium.ready; - } catch { - throw new Error( - "CHACHA20-POLY1305 requires libsodium-wrappers. Install it: npm install libsodium-wrappers", - ); - } + validateAad(aad, "CHACHA20-POLY1305"); + const sodium = await loadSodium(); iv ??= sodium.randombytes_buf(12); validateIv(iv, "CHACHA20-POLY1305"); maxInputSize ??= DEFAULT_MAX_INPUT_SIZE; @@ -267,8 +331,7 @@ const chacha20Encrypt = async ( let inputSize = 0; let authTag; const transform = (chunk) => { - const buf = - chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + const buf = chunk instanceof Uint8Array ? chunk : textEncoder.encode(chunk); inputSize += buf.byteLength; if (inputSize > maxInputSize) { throw new Error( @@ -292,33 +355,33 @@ const chacha20Encrypt = async ( }; const stream = createTransformStream(transform, flush, streamOptions); stream.result = () => ({ - key: "encrypt", + key: resultKey ?? "encrypt", value: { algorithm: "CHACHA20-POLY1305", iv, authTag }, }); return stream; }; const chacha20Decrypt = async ( - { key, iv, authTag, aad, maxOutputSize }, + { key, iv, authTag, aad, maxInputSize, maxOutputSize }, streamOptions, ) => { validateKey(key); validateAuthTag(authTag, "CHACHA20-POLY1305"); - validateAad(aad); - let sodium; - try { - sodium = await import("libsodium-wrappers"); - await sodium.ready; - } catch { - throw new Error( - "CHACHA20-POLY1305 requires libsodium-wrappers. Install it: npm install libsodium-wrappers", - ); - } + validateAad(aad, "CHACHA20-POLY1305"); + const sodium = await loadSodium(); validateIv(iv, "CHACHA20-POLY1305"); + maxInputSize ??= DEFAULT_MAX_INPUT_SIZE; const chunks = []; + let inputSize = 0; const transform = (chunk) => { - const buf = - chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + const buf = chunk instanceof Uint8Array ? chunk : textEncoder.encode(chunk); + // Bound memory before buffering/decryption (maxOutputSize is post-decrypt). + inputSize += buf.byteLength; + if (inputSize > maxInputSize) { + throw new Error( + `Decryption input exceeds maxInputSize (${maxInputSize} bytes)`, + ); + } chunks.push(buf); }; const flush = (enqueue) => { @@ -345,37 +408,57 @@ const chacha20Decrypt = async ( }; export const encryptStream = async ( - { algorithm = "AES-256-GCM", key, iv, aad, maxInputSize } = {}, + { algorithm = "AES-256-GCM", key, iv, aad, maxInputSize, resultKey } = {}, streamOptions = {}, ) => { - if (algorithm === "AES-256-GCM") { - return aesGcmEncrypt({ key, iv, aad, maxInputSize }, streamOptions); + if (algorithm === "AES-256-GCM" || algorithm === "AES-128-GCM") { + return aesGcmEncrypt( + { algorithm, key, iv, aad, maxInputSize, resultKey }, + streamOptions, + ); } - if (algorithm === "AES-256-CTR") { - return aesCtrEncrypt({ key, iv }, streamOptions); + if (algorithm === "AES-256-CTR" || algorithm === "AES-128-CTR") { + return aesCtrEncrypt( + { algorithm, key, iv, aad, maxInputSize, resultKey }, + streamOptions, + ); } if (algorithm === "CHACHA20-POLY1305") { - return chacha20Encrypt({ key, iv, aad, maxInputSize }, streamOptions); + return chacha20Encrypt( + { key, iv, aad, maxInputSize, resultKey }, + streamOptions, + ); } throw new Error(`Unsupported algorithm: ${algorithm}`); }; export const decryptStream = async ( - { algorithm = "AES-256-GCM", key, iv, authTag, aad, maxOutputSize } = {}, + { + algorithm = "AES-256-GCM", + key, + iv, + authTag, + aad, + maxInputSize, + maxOutputSize, + } = {}, streamOptions = {}, ) => { - if (algorithm === "AES-256-GCM") { + if (algorithm === "AES-256-GCM" || algorithm === "AES-128-GCM") { return aesGcmDecrypt( - { key, iv, authTag, aad, maxOutputSize }, + { algorithm, key, iv, authTag, aad, maxInputSize, maxOutputSize }, streamOptions, ); } - if (algorithm === "AES-256-CTR") { - return aesCtrDecrypt({ key, iv, maxOutputSize }, streamOptions); + if (algorithm === "AES-256-CTR" || algorithm === "AES-128-CTR") { + return aesCtrDecrypt( + { algorithm, key, iv, aad, maxOutputSize }, + streamOptions, + ); } if (algorithm === "CHACHA20-POLY1305") { return chacha20Decrypt( - { key, iv, authTag, aad, maxOutputSize }, + { key, iv, authTag, aad, maxInputSize, maxOutputSize }, streamOptions, ); } diff --git a/packages/encrypt/package.json b/packages/encrypt/package.json index 0c5344f..e5f449b 100644 --- a/packages/encrypt/package.json +++ b/packages/encrypt/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/encrypt", - "version": "0.5.0", + "version": "0.6.1", "description": "Symmetric encryption/decryption streams", "type": "module", "engines": { @@ -39,7 +39,7 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js" }, "license": "MIT", "keywords": [ @@ -60,7 +60,7 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" }, "devDependencies": { "libsodium-wrappers": ">=0.7.0" diff --git a/packages/fetch/README.md b/packages/fetch/README.md index a332fca..797c2cf 100644 --- a/packages/fetch/README.md +++ b/packages/fetch/README.md @@ -1,6 +1,6 @@

<datastream> `fetch`

- datastream logo + datastream logo

HTTP fetch streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/fetch/index.js b/packages/fetch/index.js index 865b94a..f0f1491 100644 --- a/packages/fetch/index.js +++ b/packages/fetch/index.js @@ -22,6 +22,11 @@ const validatePaginationUrl = (nextUrl, origin) => { } }; +// URL.parse returns null (instead of throwing) on an unparseable URL, so the +// origin falls through to undefined without a try/catch — avoiding an +// error-swallowing catch body that is indistinguishable from an empty one. +const originOf = (urlString) => URL.parse(urlString)?.origin; + const redactUrl = (urlString) => { try { const url = new URL(urlString); @@ -79,7 +84,13 @@ export const fetchWritableStream = async (options, streamOptions = {}) => { const write = (chunk) => { body.push(chunk); }; - const stream = createWritableStream(write, streamOptions); + // Signal end-of-body so the duplex request body terminates and the + // in-flight upload can complete. createReadableStream treats a pushed + // `null` as close on both the Node and Web builds. + const final = () => { + body.push(null); + }; + const stream = createWritableStream(write, final, streamOptions); stream.result = () => ({ key: "output", value }); return stream; }; @@ -117,7 +128,11 @@ async function* fetchGenerator(fetchOptions, streamOptions) { yield chunk; } } catch (error) { + // Binary branch: response is a Web ReadableStream with .cancel(). + // JSON branch: response is the fetchJson async generator with + // .return(). Call both so resources are released uniformly. await response?.cancel?.(); + await response?.return?.(); throw error; } // ensure there is rate limiting between req with different options @@ -125,7 +140,11 @@ async function* fetchGenerator(fetchOptions, streamOptions) { } } -const jsonContentTypeRegExp = /^application\/(.+\+)?json($|;.+)/; +// `json` optionally followed by `;parameters` (or a bare trailing `;`). +// Note: the parameter portion is intentionally NOT `;.+` — that form admits a +// Stryker-equivalent mutant (`;.+` and `;.` accept the exact same inputs under +// `.test()`), so we match a bare `;` and let any following parameters be free. +const jsonContentTypeRegExp = /^application\/(.+\+)?json($|;)/; const fetchUnknown = async (options, streamOptions) => { const response = await fetchRateLimit(options, streamOptions); if (jsonContentTypeRegExp.test(response.headers.get("Content-Type"))) { @@ -146,6 +165,11 @@ async function* fetchJson(options, streamOptions) { options.prefetchResponse ?? (await fetchRateLimit(options, streamOptions)); delete options.prefetchResponse; + // NOTE: response.json() buffers the FULL body of this page into memory + // before any item is yielded — unlike the binary branch which streams + // chunk-by-chunk with backpressure. A server returning a very large + // single JSON document (or large pages) is fully materialized here, so + // keep per-page payloads bounded for untrusted endpoints. const body = await response.json(); url = parseLinkFromHeader(response.headers); url ??= parseNextPath(body, nextPath); @@ -187,13 +211,34 @@ const parseLinkFromHeader = (headers) => { return link?.match(nextLinkRegExp)?.[1]; }; -export const fetchRateLimit = async (options, streamOptions = {}) => { +// 3xx statuses that carry a Location and represent a redirect. +const redirectStatuses = new Set([301, 302, 303, 307, 308]); +const maxRedirects = 20; + +export const fetchRateLimit = async (options = {}, streamOptions = {}) => { + // Apply defaults FIRST so rateLimit (and every other option) is populated + // before it is read; otherwise a direct call without rateLimit computes + // `Date.now() + 1000 * undefined` = NaN for rateLimitTimestamp. + // Mutate the passed-in object in place (rather than reassigning to a clone) + // so callers — notably fetchGenerator, which reads back + // options.rateLimitTimestamp to carry rate limiting between configs — see + // the timestamp we compute below. + Object.assign(options, mergeOptions(options)); + const now = Date.now(); if (now < (options.rateLimitTimestamp ?? 0)) { await timeout(options.rateLimitTimestamp - now, streamOptions); } options.rateLimitTimestamp = Date.now() + 1000 * options.rateLimit; - options = mergeOptions(options); // for when called directly + + // Same-origin redirect pinning (SSRF defence). The initial origin is pinned + // to the original request URL; redirects to a different origin are blocked + // by default so a server cannot 3xx us into internal/metadata endpoints. + // Callers can opt out by setting `redirect` explicitly ("follow"/"error"). + const callerRedirect = options.redirect; + const manageRedirects = callerRedirect === undefined; + // __origin is set by fetchGenerator; derive it for direct callers. + const initialOrigin = options.__origin ?? originOf(options.url); const { method, @@ -202,7 +247,6 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { mode, credentials, cache, - redirect, referrer, referrerPolicy, integrity, @@ -216,7 +260,9 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { mode, credentials, cache, - redirect, + // When we manage redirects ourselves we ask the platform NOT to follow + // them so we can validate each Location before re-issuing. + redirect: manageRedirects ? "manual" : callerRedirect, referrer, referrerPolicy, integrity, @@ -224,7 +270,67 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { duplex, signal: streamOptions.signal, }; - const response = await fetch(options.url, fetchInit); + let response = await fetch(options.url, fetchInit); + + // Manually follow / validate redirects when we own redirect handling. + if (manageRedirects) { + let redirectCount = 0; + while ( + redirectStatuses.has(response.status) && + response.headers.has("Location") + ) { + const safeUrl = redactUrl(options.url); + if (++redirectCount > maxRedirects) { + await response.body?.cancel(); + throw new Error( + `fetch ${options.method} ${safeUrl} exceeded ${maxRedirects} redirects`, + { + cause: { + status: response.status, + url: safeUrl, + method: options.method, + }, + }, + ); + } + const location = response.headers.get("Location"); + let target; + try { + target = new URL(location, options.url); + } catch { + await response.body?.cancel(); + throw new Error( + `fetch ${options.method} ${safeUrl} returned an invalid redirect Location`, + { + cause: { + status: response.status, + url: safeUrl, + method: options.method, + }, + }, + ); + } + if (target.origin !== initialOrigin) { + await response.body?.cancel(); + throw new Error( + `fetch ${options.method} ${safeUrl} blocked cross-origin redirect (${target.origin} does not match ${initialOrigin})`, + { + cause: { + status: response.status, + url: safeUrl, + location: redactUrl(target.toString()), + origin: initialOrigin, + method: options.method, + }, + }, + ); + } + await response.body?.cancel(); + options.url = target.toString(); + response = await fetch(options.url, fetchInit); + } + } + if (!response.ok) { const safeUrl = redactUrl(options.url); // 429 Too Many Requests @@ -238,7 +344,7 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { { cause: { status: response.status, - url: options.url, + url: safeUrl, method: options.method, }, }, @@ -258,7 +364,7 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { throw new Error(`fetch ${response.status} ${options.method} ${safeUrl}`, { cause: { status: response.status, - url: options.url, + url: safeUrl, method: options.method, }, }); diff --git a/packages/fetch/index.test.js b/packages/fetch/index.test.js index 5c20385..9790921 100644 --- a/packages/fetch/index.test.js +++ b/packages/fetch/index.test.js @@ -4,6 +4,7 @@ import { deepStrictEqual, ok, strictEqual } from "node:assert"; import test from "node:test"; import { createPassThroughStream, + createTransformStream, pipejoin, pipeline, streamToArray, @@ -165,6 +166,32 @@ global.fetch = (url, _request) => { throw new Error("mock missing"); }; +// *** built-in default Accept header (literal, before any pollution) *** // +// MUST run before any fetchSetDefaults({headers:{Accept:...}}) call below so it +// observes the un-overridden literal default `Accept: "application/json"`. +// Kills the StringLiteral mutant that empties the literal to "". +test(`${variant}: built-in default Accept header is non-empty application/json`, async (_t) => { + const originalFetch = global.fetch; + let capturedHeaders; + global.fetch = async (_url, init) => { + capturedHeaders = init.headers; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + try { + // Do NOT touch Accept anywhere: rely entirely on the literal default. + const stream = fetchResponseStream([ + { url: "https://example.org/builtin-accept", dataPath: "" }, + ]); + await streamToArray(stream); + strictEqual(capturedHeaders.Accept, "application/json"); + } finally { + global.fetch = originalFetch; + } +}); + // *** fetchResponseStream *** // test(`${variant}: fetchResponseStream should fetch csv`, async (_t) => { fetchSetDefaults({ headers: { Accept: "text/csv" } }); @@ -333,6 +360,238 @@ test(`${variant}: fetchResponseStream should throw on non-ok response`, async (_ } }); +test(`${variant}: fetchResponseStream error cause must not leak the unredacted URL`, async (_t) => { + const originalFetch = global.fetch; + const secretUrl = + "https://user:pass@example.org/secret?token=abc123&api_key=zzz"; + global.fetch = async (url) => { + if (url.startsWith("https://user:pass@example.org/secret")) { + return new Response("", { status: 404, statusText: "Not Found" }); + } + return originalFetch(url); + }; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([{ url: secretUrl }]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (error) { + // message is redacted + ok(!error.message.includes("token=abc123"), "message leaked query token"); + ok(!error.message.includes("user:pass"), "message leaked credentials"); + // cause.url MUST be redacted too + ok( + !String(error.cause.url).includes("token=abc123"), + `cause.url leaked query token: ${error.cause.url}`, + ); + ok( + !String(error.cause.url).includes("api_key=zzz"), + `cause.url leaked api_key: ${error.cause.url}`, + ); + ok( + !String(error.cause.url).includes("user:pass"), + `cause.url leaked credentials: ${error.cause.url}`, + ); + ok( + String(error.cause.url).includes("[REDACTED]"), + `cause.url not redacted: ${error.cause.url}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + +test(`${variant}: fetchRateLimit 429 max-retries error cause must not leak the unredacted URL`, async (_t) => { + const originalFetch = global.fetch; + const secretUrl = "https://example.org/always-429?token=secret-429"; + global.fetch = () => + Promise.resolve( + new Response("rate limited", { + status: 429, + statusText: "Too Many Requests", + }), + ); + fetchSetDefaults({ rateLimit: 0 }); + try { + const stream = fetchResponseStream([ + { url: secretUrl, rateLimit: 0, retryMaxCount: 2 }, + ]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (error) { + ok(error.message.includes("max retries")); + ok( + !error.message.includes("token=secret-429"), + "message leaked query token", + ); + ok( + !String(error.cause.url).includes("token=secret-429"), + `cause.url leaked query token: ${error.cause.url}`, + ); + ok( + String(error.cause.url).includes("[REDACTED]"), + `cause.url not redacted: ${error.cause.url}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** SSRF: redirect handling *** // +test(`${variant}: fetchResponseStream should block cross-origin redirects (SSRF)`, async (_t) => { + const originalFetch = global.fetch; + let metadataFetched = false; + global.fetch = async (url, init) => { + if (url === "https://example.org/redirect-ssrf") { + // Simulate a server 302-redirecting to a cloud metadata endpoint. + // With redirect:"manual" the platform returns the 3xx unfollowed. + deepStrictEqual(init.redirect, "manual"); + return new Response("", { + status: 302, + statusText: "Found", + headers: new Headers({ + Location: "http://169.254.169.254/latest/meta-data/", + }), + }); + } + if (url === "http://169.254.169.254/latest/meta-data/") { + metadataFetched = true; + return new Response("creds", { status: 200 }); + } + return originalFetch(url); + }; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/redirect-ssrf" }, + ]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (error) { + ok( + error.message.includes("redirect"), + `expected redirect error, got: ${error.message}`, + ); + strictEqual(metadataFetched, false, "cross-origin redirect was followed"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + +test(`${variant}: fetchResponseStream cross-origin redirect error must not leak unredacted URL`, async (_t) => { + const originalFetch = global.fetch; + const secretUrl = "https://example.org/redirect-secret?token=abc123"; + global.fetch = async (url) => { + if (url === secretUrl) { + return new Response("", { + status: 301, + statusText: "Moved Permanently", + headers: new Headers({ + Location: "http://127.0.0.1:9000/internal?password=leak", + }), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([{ url: secretUrl }]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (error) { + ok( + !error.message.includes("token=abc123"), + "redirect error leaked source token", + ); + ok( + !error.message.includes("password=leak"), + "redirect error leaked destination secret", + ); + ok( + !String(error.cause?.url ?? "").includes("token=abc123"), + `cause.url leaked source token: ${error.cause?.url}`, + ); + ok( + !String(error.cause?.location ?? "").includes("password=leak"), + `cause.location leaked destination secret: ${error.cause?.location}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + +test(`${variant}: fetchResponseStream should follow same-origin redirects`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/redirect-same") { + return new Response("", { + status: 302, + statusText: "Found", + headers: new Headers({ + Location: "https://example.org/redirect-target", + }), + }); + } + if (url === "https://example.org/redirect-target") { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ dataPath: "", headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/redirect-same" }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ + dataPath: undefined, + headers: { Accept: "application/json" }, + }); + } +}); + +test(`${variant}: fetchResponseStream should honor explicit redirect option (opt-in follow)`, async (_t) => { + const originalFetch = global.fetch; + let sawFollow = false; + global.fetch = async (url, init) => { + if (url === "https://example.org/redirect-optin") { + sawFollow = init.redirect === "follow"; + // With redirect:"follow" the platform resolves the redirect itself, + // so the mock just returns the final response. + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ dataPath: "", headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/redirect-optin", redirect: "follow" }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + ok(sawFollow, "explicit redirect:follow was not passed through"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ + dataPath: undefined, + headers: { Accept: "application/json" }, + }); + } +}); + // *** fetchWritableStream *** // test(`${variant}: fetchWritableStream should create writable stream for upload`, async (_t) => { const originalFetch = global.fetch; @@ -364,7 +623,47 @@ test(`${variant}: fetchWritableStream should create writable stream for upload`, global.fetch = originalFetch; }); -test(`${variant}: fetchRateLimit should handle rate limit delay`, async (_t) => { +test(`${variant}: fetchWritableStream should close the request body on end`, async (_t) => { + const originalFetch = global.fetch; + + let resolveBody; + const bodyDone = new Promise((resolve) => { + resolveBody = resolve; + }); + + global.fetch = async (_url, options) => { + // Drain the duplex request body to completion in the background. + // If end-of-body is never signaled, this never resolves and the + // test times out. + (async () => { + const received = []; + for await (const chunk of options.body) { + received.push(chunk); + } + resolveBody(received); + })(); + return new Response(JSON.stringify({ uploaded: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + + const stream = await fetchWritableStream({ + url: "https://example.org/upload", + method: "POST", + }); + + stream.write("alpha"); + stream.write("beta"); + stream.end(); + + const received = await bodyDone; + deepStrictEqual(received, ["alpha", "beta"]); + + global.fetch = originalFetch; +}); + +test(`${variant}: fetchRateLimit should delay between configs within one stream`, async (_t) => { const originalFetch = global.fetch; const fetchCallTimes = []; @@ -376,21 +675,46 @@ test(`${variant}: fetchRateLimit should handle rate limit delay`, async (_t) => }); }; - fetchSetDefaults({ rateLimit: 0.1 }); // 100ms delay - - const config1 = [{ url: "https://example.org/test1" }]; - const stream1 = fetchResponseStream(config1); - await streamToArray(stream1); - - const config2 = [{ url: "https://example.org/test2" }]; - const stream2 = fetchResponseStream(config2); - await streamToArray(stream2); + // A single config array of >=2 entries so the in-generator + // rateLimitTimestamp carries over between the two requests. + const config = [ + { url: "https://example.org/test1", rateLimit: 0.1, dataPath: "" }, + { url: "https://example.org/test2", rateLimit: 0.1, dataPath: "" }, + ]; + const stream = fetchResponseStream(config); + await streamToArray(stream); global.fetch = originalFetch; - fetchSetDefaults({ rateLimit: 0.01 }); // Reset to default - // Second call should have been delayed strictEqual(fetchCallTimes.length, 2); + // rateLimit 0.1 => ~100ms enforced delay between the two calls. + ok( + fetchCallTimes[1] - fetchCallTimes[0] >= 90, + `expected >=~100ms delay between configs, got ${ + fetchCallTimes[1] - fetchCallTimes[0] + }ms`, + ); +}); + +test(`${variant}: fetchRateLimit direct call defaults rateLimit before computing timestamp`, async (_t) => { + const { fetchRateLimit } = await import("@datastream/fetch"); + const originalFetch = global.fetch; + global.fetch = async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + try { + // Direct call with no rateLimit in options: timestamp must NOT be NaN. + const options = { url: "https://example.org/direct-rl" }; + await fetchRateLimit(options); + ok( + Number.isFinite(options.rateLimitTimestamp), + `rateLimitTimestamp should be finite, got ${options.rateLimitTimestamp}`, + ); + } finally { + global.fetch = originalFetch; + } }); test(`${variant}: fetchResponseStream should handle content-type with suffix`, async (_t) => { @@ -504,6 +828,158 @@ test(`${variant}: fetchRateLimit should throw after max retries on persistent 42 } }); +// *** JSON pagination cleanup on downstream error *** // +test(`${variant}: fetchResponseStream should stop JSON pagination when consumer errors`, async (_t) => { + const originalFetch = global.fetch; + let page2Fetched = false; + global.fetch = async (url) => { + if (url === "https://example.org/cancel-json/1") { + return new Response( + JSON.stringify({ + data: [{ id: 1 }, { id: 2 }], + next: "https://example.org/cancel-json/2", + }), + { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }, + ); + } + if (url === "https://example.org/cancel-json/2") { + page2Fetched = true; + return new Response(JSON.stringify({ data: [{ id: 3 }], next: "" }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + } + return originalFetch(url); + }; + + fetchSetDefaults({ dataPath: "data", nextPath: "next" }); + const failing = createTransformStream(() => { + throw new Error("downstream boom"); + }); + try { + await pipeline([ + fetchResponseStream({ url: "https://example.org/cancel-json/1" }), + failing, + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("downstream boom")); + // On error, the JSON generator must be returned so pagination stops; + // page 2 must never be fetched. + strictEqual(page2Fetched, false); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ dataPath: undefined, nextPath: undefined }); + } +}); + +// *** Binary branch cleanup on downstream error *** +// When the response is NON-JSON, fetchGenerator iterates the raw ReadableStream +// (response.body), which has .cancel() but NO .return(). A downstream consumer +// error must propagate; the catch block calls response?.cancel?.() then +// response?.return?.(). The ReadableStream lacks .return, so the inner optional +// chaining on `.return` MUST be preserved — a mutant turning `response?.return?.()` +// into `response?.return()` would throw "response.return is not a function" and +// mask the real "downstream boom" error. +test(`${variant}: fetchResponseStream binary branch cleanup on read error preserves original error`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/binary-cancel") { + // text/csv → non-JSON → binary branch: fetchGenerator iterates + // response.body directly. We install a body that HAS .cancel() + // (resolving cleanly) but NO .return(), and whose async iterator + // throws — so the catch runs, response?.cancel?.() succeeds, and we + // reach response?.return?.() with response lacking .return. + const r = new Response("ignored", { + status: 200, + headers: new Headers({ "Content-Type": "text/csv" }), + }); + const fakeBody = { + cancel: async () => {}, + [Symbol.asyncIterator]() { + return { + next: async () => { + throw new Error("binary read boom"); + }, + }; + }, + }; + Object.defineProperty(r, "body", { + value: fakeBody, + configurable: true, + }); + return r; + } + return originalFetch(url); + }; + fetchSetDefaults({ headers: { Accept: "text/csv" } }); + try { + const stream = fetchResponseStream({ + url: "https://example.org/binary-cancel", + }); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (e) { + // Original code: response?.return?.() no-ops (stream lacks .return), so the + // ORIGINAL read error surfaces. Mutant `response?.return()` calls a + // non-function and throws "response.return is not a function", masking it. + ok( + e.message.includes("binary read boom"), + `expected original read error, got: ${e.message}`, + ); + ok( + !e.message.includes("is not a function"), + `cleanup called a non-existent .return(): ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + +// *** Null response cleanup: catch fires with response === null/undefined *** +// A non-JSON response whose body is null makes fetchUnknown return null, so +// `for await (const chunk of null)` throws and the catch block runs with +// `response === null`. The outer optional chaining `response?.cancel?.()` and +// `response?.return?.()` MUST be preserved: a mutant removing the first `?.` +// (response.cancel?.() / response.return()) dereferences null and throws a +// "Cannot read properties of null" TypeError, replacing the genuine iteration +// error. We assert the surfaced error is the iteration error, not the null deref. +test(`${variant}: fetchResponseStream null response body surfaces iteration error not null-deref`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => { + const r = new Response("ignored", { + status: 200, + headers: new Headers({ "Content-Type": "text/csv" }), + }); + // Force a null body so fetchUnknown returns null and iteration throws. + Object.defineProperty(r, "body", { value: null, configurable: true }); + return r; + }; + fetchSetDefaults({ headers: { Accept: "text/csv" } }); + try { + const stream = fetchResponseStream({ + url: "https://example.org/null-binary-body", + }); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (e) { + // Original code: re-throws the iteration error (null is not iterable). + // Mutant (response.cancel?.()): throws "Cannot read properties of null + // (reading 'cancel')" BEFORE reaching `throw error`. + ok( + !/reading 'cancel'|reading 'return'/.test(e.message), + `cleanup dereferenced null instead of guarding it: ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + // *** SSRF protection: same-origin pagination *** // test(`${variant}: fetchResponseStream should reject Link header with cross-origin URL`, async (_t) => { const originalFetch = global.fetch; @@ -653,3 +1129,1505 @@ test(`${variant}: default export should include all stream functions`, (_t) => { "setDefaults", ]); }); + +// *** validatePaginationUrl: invalid URL catch block and error message *** // +test(`${variant}: fetchResponseStream should throw with message for invalid pagination URL in Link header`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/bad-link") { + return new Response(JSON.stringify({ data: [{ id: 1 }] }), { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + Link: '; rel="next"', + }), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ dataPath: "data" }); + try { + const stream = fetchResponseStream({ url: "https://example.org/bad-link" }); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("Invalid pagination URL"), + `expected invalid pagination URL error, got: ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + } +}); + +// *** redactUrl: username/password redaction uses [REDACTED] not empty string *** // +test(`${variant}: fetchResponseStream error cause.url contains [REDACTED] not empty credentials`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => + new Response("", { status: 404, statusText: "Not Found" }); + fetchSetDefaults({}); + try { + const { fetchRateLimit } = await import("@datastream/fetch"); + await fetchRateLimit({ url: "https://user:pass@example.org/path?q=1" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.cause.url.includes("[REDACTED]"), + `cause.url should have [REDACTED], got: ${e.cause.url}`, + ); + ok( + !e.cause.url.includes("user"), + `cause.url should not have username, got: ${e.cause.url}`, + ); + ok( + !e.cause.url.includes("pass"), + `cause.url should not have password, got: ${e.cause.url}`, + ); + ok( + !e.cause.url.includes(":@"), + `cause.url should not have empty-string credentials, got: ${e.cause.url}`, + ); + } finally { + global.fetch = originalFetch; + } +}); + +// *** defaults headers: Accept and Accept-Encoding must not be empty *** // +test(`${variant}: default headers include non-empty Accept and Accept-Encoding`, async (_t) => { + const originalFetch = global.fetch; + let capturedHeaders; + global.fetch = async (_url, init) => { + capturedHeaders = init.headers; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + fetchSetDefaults({}); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/headers-test", dataPath: "" }, + ]); + await streamToArray(stream); + strictEqual(capturedHeaders.Accept, "application/json"); + strictEqual(capturedHeaders["Accept-Encoding"], "br, gzip, deflate"); + } finally { + global.fetch = originalFetch; + } +}); + +// *** mergeOptions: headers must be merged object, not {} *** // +test(`${variant}: fetchSetDefaults merges headers correctly and preserves defaults`, async (_t) => { + const originalFetch = global.fetch; + let capturedHeaders; + global.fetch = async (_url, init) => { + capturedHeaders = init.headers; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + fetchSetDefaults({ headers: { "X-Custom": "test" } }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/merge-headers", dataPath: "" }, + ]); + await streamToArray(stream); + strictEqual(capturedHeaders["X-Custom"], "test"); + ok( + capturedHeaders.Accept !== undefined, + "Accept header should be present after merge", + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** duplex: "half" not empty string *** // +test(`${variant}: fetchWritableStream sends duplex:"half" not empty string`, async (_t) => { + const originalFetch = global.fetch; + let capturedInit; + global.fetch = async (_url, init) => { + capturedInit = init; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + fetchSetDefaults({}); + const stream = await fetchWritableStream({ + url: "https://example.org/duplex", + method: "POST", + }); + stream.end(); + await new Promise((r) => setTimeout(r, 10)); + strictEqual(capturedInit.duplex, "half"); + global.fetch = originalFetch; +}); + +// *** URL + → %20 replacement *** // +test(`${variant}: fetchResponseStream replaces + with %20 in query string`, async (_t) => { + const originalFetch = global.fetch; + let capturedUrl; + global.fetch = async (url) => { + capturedUrl = url; + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + fetchSetDefaults({ dataPath: "data" }); + const stream = fetchResponseStream({ + url: "https://example.org/space-test", + qs: { q: "hello world" }, + dataPath: "data", + }); + await streamToArray(stream); + global.fetch = originalFetch; + ok( + capturedUrl.includes("%20"), + `URL should use %20 not +, got: ${capturedUrl}`, + ); + ok( + !capturedUrl.includes("+"), + `URL should not contain +, got: ${capturedUrl}`, + ); +}); + +// *** JSON content-type regex: must anchor at start *** // +test(`${variant}: fetchResponseStream treats text/application-json (non-JSON) as binary`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => { + return new Response("raw bytes", { + status: 200, + headers: new Headers({ "Content-Type": "text/application/json" }), + }); + }; + fetchSetDefaults({ dataPath: "" }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/text-json" }, + ]); + const output = await streamToArray(stream); + ok(output.length > 0); + ok( + output[0] instanceof Uint8Array, + `Expected binary output for non-JSON content type, got: ${typeof output[0]}`, + ); + } finally { + global.fetch = originalFetch; + } +}); + +// *** JSON content-type regex: json must be followed by end-of-string or `;` *** +// `application/jsonl` (JSON Lines) shares the `application/json` prefix but is a +// distinct, line-delimited (binary) format. The trailing `($|;)` group ensures +// it is NOT mis-detected as a single JSON document. Kills a mutant that drops +// the group (matching any `application/json...` suffix). +test(`${variant}: fetchResponseStream treats application/jsonl (no separator) as binary`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => { + return new Response('{"a":1}\n{"a":2}', { + status: 200, + headers: new Headers({ "Content-Type": "application/jsonl" }), + }); + }; + fetchSetDefaults({ dataPath: "" }); + try { + const stream = fetchResponseStream([{ url: "https://example.org/jsonl" }]); + const output = await streamToArray(stream); + ok(output.length > 0); + ok( + output[0] instanceof Uint8Array, + `Expected binary output for application/jsonl, got: ${typeof output[0]}`, + ); + } finally { + global.fetch = originalFetch; + } +}); + +// *** JSON content-type regex: end anchor allows ; but not random suffix *** // +test(`${variant}: fetchResponseStream treats application/json;charset=utf-8 as JSON`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => { + return new Response(JSON.stringify({ val: 42 }), { + status: 200, + headers: new Headers({ + "Content-Type": "application/json;charset=utf-8", + }), + }); + }; + fetchSetDefaults({ dataPath: "" }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/json-charset2" }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ val: 42 }]); + } finally { + global.fetch = originalFetch; + } +}); + +// *** Link header match: optional chaining on null match result *** // +test(`${variant}: fetchResponseStream handles Link header with no rel=next match`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/link-no-next") { + return new Response(JSON.stringify({ data: [{ id: 1 }] }), { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + Link: '; rel="prev"', + }), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ dataPath: "data" }); + try { + const stream = fetchResponseStream({ + url: "https://example.org/link-no-next", + }); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ id: 1 }]); + } finally { + global.fetch = originalFetch; + } +}); + +// *** rateLimit: now < vs now <= timestamp *** // +test(`${variant}: fetchRateLimit does not wait when rateLimitTimestamp is in the past`, async (_t) => { + const { fetchRateLimit } = await import("@datastream/fetch"); + const originalFetch = global.fetch; + const fetchCallTimes = []; + global.fetch = async () => { + fetchCallTimes.push(Date.now()); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + fetchSetDefaults({ rateLimit: 0 }); + try { + const past = Date.now() - 1000; + await fetchRateLimit({ + url: "https://example.org/rl-past1", + rateLimitTimestamp: past, + rateLimit: 0, + }); + await fetchRateLimit({ + url: "https://example.org/rl-past2", + rateLimitTimestamp: past, + rateLimit: 0, + }); + ok(fetchCallTimes.length === 2); + ok( + fetchCallTimes[1] - fetchCallTimes[0] < 200, + `Expected minimal delay, got ${fetchCallTimes[1] - fetchCallTimes[0]}ms`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** initialOrigin: ?? vs && *** // +test(`${variant}: fetchRateLimit without __origin derives origin from URL for redirect SSRF check`, async (_t) => { + const originalFetch = global.fetch; + let ssrfAttempted = false; + global.fetch = async (url) => { + if (url === "https://example.org/no-origin-redirect") { + return new Response("", { + status: 302, + statusText: "Found", + headers: new Headers({ Location: "http://169.254.169.254/metadata" }), + }); + } + if (url === "http://169.254.169.254/metadata") { + ssrfAttempted = true; + return new Response("creds", { status: 200 }); + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/no-origin-redirect" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("redirect") || e.message.includes("origin"), + `expected redirect/origin error, got: ${e.message}`, + ); + strictEqual( + ssrfAttempted, + false, + "SSRF redirect was followed even without __origin", + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** manageRedirects: explicit redirect option is passed through *** // +test(`${variant}: fetchRateLimit with explicit redirect option passes it through to fetch`, async (_t) => { + const originalFetch = global.fetch; + let seenRedirectOption; + global.fetch = async (_url, init) => { + seenRedirectOption = init.redirect; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ + url: "https://example.org/explicit-follow", + redirect: "follow", + }); + strictEqual(seenRedirectOption, "follow"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** redirect while: && vs || condition *** // +test(`${variant}: fetchRateLimit does not loop on 302 response without Location header`, async (_t) => { + const originalFetch = global.fetch; + let callCount = 0; + global.fetch = async (url) => { + callCount++; + if (url === "https://example.org/redirect-no-location") { + return new Response("", { + status: 302, + statusText: "Found", + headers: new Headers({}), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ dataPath: "" }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/redirect-no-location" }, + ]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("302"), `Expected 302 error, got: ${e.message}`); + strictEqual(callCount, 1, `Expected 1 fetch call, got ${callCount}`); + } finally { + global.fetch = originalFetch; + } +}); + +// *** maxRedirects: > vs >= and ++ vs -- *** // +test(`${variant}: fetchRateLimit blocks after exactly maxRedirects (20) same-origin redirects`, async (_t) => { + const originalFetch = global.fetch; + let redirectCount = 0; + global.fetch = async (url) => { + redirectCount++; + if (url.startsWith("https://example.org/redir-")) { + const n = parseInt(url.split("-").pop(), 10); + return new Response("", { + status: 302, + statusText: "Found", + headers: new Headers({ + Location: `https://example.org/redir-${n + 1}`, + }), + }); + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/redir-0" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("exceeded") || e.message.includes("redirect"), + `Expected max redirect error, got: ${e.message}`, + ); + ok( + redirectCount >= 20 && redirectCount <= 22, + `Expected ~21 redirect calls (>20), got ${redirectCount}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** maxRedirects error cause must have populated cause object *** // +test(`${variant}: fetchRateLimit max-redirect error has correct cause fields`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url.startsWith("https://example.org/cause-redir-")) { + const n = parseInt(url.split("-").pop(), 10); + return new Response("", { + status: 301, + headers: new Headers({ + Location: `https://example.org/cause-redir-${n + 1}`, + }), + }); + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/cause-redir-0" }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.cause !== undefined, "cause should be defined"); + strictEqual(typeof e.cause.status, "number"); + strictEqual(typeof e.cause.url, "string"); + strictEqual(typeof e.cause.method, "string"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** invalid redirect Location: catch block and error cause *** // +test(`${variant}: fetchRateLimit throws for invalid redirect Location with correct cause`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/bad-location") { + return new Response("", { + status: 301, + headers: new Headers({ Location: "not-a-valid::url" }), + }); + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/bad-location" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.length > 0, + `Expected non-empty error message, got: ${e.message}`, + ); + ok(e.cause !== undefined, "cause should be defined"); + strictEqual(typeof e.cause.status, "number"); + strictEqual(typeof e.cause.url, "string"); + strictEqual(typeof e.cause.method, "string"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** cross-origin redirect error cause fields (location, origin) *** // +test(`${variant}: fetchRateLimit cross-origin redirect error cause has location and origin fields`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/ssrf-cause") { + return new Response("", { + status: 302, + headers: new Headers({ Location: "http://169.254.169.254/meta" }), + }); + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/ssrf-cause" }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.cause !== undefined, "cause should be defined"); + strictEqual(typeof e.cause.status, "number"); + strictEqual(typeof e.cause.url, "string"); + strictEqual(typeof e.cause.location, "string"); + strictEqual(typeof e.cause.origin, "string"); + strictEqual(typeof e.cause.method, "string"); + strictEqual(e.cause.origin, "https://example.org"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** 429 retryCount >= vs > retryMaxCount *** // +test(`${variant}: fetchRateLimit throws at exactly retryMaxCount retries not retryMaxCount+1`, async (_t) => { + const originalFetch = global.fetch; + let callCount = 0; + global.fetch = () => { + callCount++; + return Promise.resolve( + new Response("", { status: 429, statusText: "Too Many Requests" }), + ); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({ rateLimit: 0 }); + try { + await fetchRateLimit({ + url: "https://example.org/retry-exact", + rateLimit: 0, + retryMaxCount: 3, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("max retries"), + `Expected max retries error, got: ${e.message}`, + ); + strictEqual( + callCount, + 3, + `Expected exactly 3 fetch calls (retryMaxCount=3), got ${callCount}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** 429 retry cause fields *** // +test(`${variant}: fetchRateLimit max retry error cause has correct fields`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = () => + Promise.resolve( + new Response("", { status: 429, statusText: "Too Many Requests" }), + ); + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({ rateLimit: 0 }); + try { + await fetchRateLimit({ + url: "https://example.org/retry-cause", + rateLimit: 0, + retryMaxCount: 1, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.cause !== undefined, "cause should be defined"); + strictEqual(e.cause.status, 429); + strictEqual(typeof e.cause.url, "string"); + strictEqual(typeof e.cause.method, "string"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** non-ok (non-429) error cause fields *** // +test(`${variant}: fetchRateLimit non-ok error cause has status, url, method fields`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = () => + Promise.resolve( + new Response("", { status: 503, statusText: "Service Unavailable" }), + ); + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/503" }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.cause !== undefined, "cause should be defined"); + strictEqual(e.cause.status, 503); + strictEqual(typeof e.cause.url, "string"); + strictEqual(typeof e.cause.method, "string"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** Retry-After header: baseMs = retryAfter * 1000 (not / 1000) *** // +test(`${variant}: fetchResponseStream respects Retry-After header as seconds multiplied by 1000`, async (_t) => { + const originalFetch = global.fetch; + let callCount = 0; + const startTime = Date.now(); + let secondCallTime; + global.fetch = (url) => { + callCount++; + if (callCount === 1 && url === "https://example.org/retry-after") { + return Promise.resolve( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: new Headers({ "Retry-After": "1" }), + }), + ); + } + secondCallTime = Date.now(); + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }), + ); + }; + fetchSetDefaults({ rateLimit: 0 }); + try { + const stream = fetchResponseStream([ + { + url: "https://example.org/retry-after", + rateLimit: 0, + retryMaxCount: 2, + dataPath: "", + }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + ok( + secondCallTime - startTime >= 900, + `Expected >=~1000ms delay from Retry-After:1, got ${secondCallTime - startTime}ms`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** backoffMs: Math.random() * baseMs succeeds (retry works without crash) *** // +test(`${variant}: fetchResponseStream without Retry-After retries successfully after backoff`, async (_t) => { + const originalFetch = global.fetch; + let callCount = 0; + global.fetch = (url) => { + callCount++; + if (callCount === 1 && url === "https://example.org/no-retry-after") { + return Promise.resolve( + new Response("", { status: 429, statusText: "Too Many Requests" }), + ); + } + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }), + ); + }; + fetchSetDefaults({ rateLimit: 0 }); + try { + const stream = fetchResponseStream([ + { + url: "https://example.org/no-retry-after", + rateLimit: 0, + retryMaxCount: 10, + dataPath: "", + }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + strictEqual(callCount, 2); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** pickPath: default "" (not "Stryker was here!") and optional chaining *** // +test(`${variant}: fetchResponseStream with empty string dataPath returns whole body as single item`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => { + return new Response(JSON.stringify({ root: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + fetchSetDefaults({ dataPath: "" }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/root-path", dataPath: "" }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ root: true }]); + } finally { + global.fetch = originalFetch; + } +}); + +test(`${variant}: fetchResponseStream with null in dataPath chain does not throw (optional chaining)`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => { + return new Response(JSON.stringify({ data: null }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + fetchSetDefaults({}); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/null-path", dataPath: "data.items" }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [undefined]); + } finally { + global.fetch = originalFetch; + } +}); + +// *** validatePaginationUrl: null/undefined nextUrl must return without throwing *** // +test(`${variant}: fetchResponseStream stops pagination cleanly when nextPath returns null`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => + new Response(JSON.stringify({ data: [{ id: 1 }], next: null }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + fetchSetDefaults({ dataPath: "data", nextPath: "next" }); + try { + const stream = fetchResponseStream({ + url: "https://example.org/null-next-vpu", + }); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ id: 1 }]); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ dataPath: undefined, nextPath: undefined }); + } +}); + +test(`${variant}: fetchResponseStream stops pagination cleanly when nextPath returns undefined`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => + new Response(JSON.stringify({ data: [{ id: 2 }] }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + fetchSetDefaults({ dataPath: "data", nextPath: "next" }); + try { + const stream = fetchResponseStream({ + url: "https://example.org/undefined-next-vpu", + }); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ id: 2 }]); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ dataPath: undefined, nextPath: undefined }); + } +}); + +// *** rateLimit gate: `if (now < ts)` must NOT always-enter and `<` not `<=` *** +// Timing alone cannot distinguish the mutants (the wait amount is <= 0 when the +// gate would be wrongly entered, so setTimeout fires immediately). Instead we +// pass an ALREADY-ABORTED signal: `timeout()` rejects synchronously on an +// aborted signal, so any *unwanted* entry into the wait branch surfaces as an +// "Aborted" rejection. Original code (gate false) skips the wait entirely. +test(`${variant}: fetchRateLimit does not enter wait branch when timestamp is in the past (if(true) mutant)`, async (_t) => { + const { fetchRateLimit } = await import("@datastream/fetch"); + const originalFetch = global.fetch; + global.fetch = async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + fetchSetDefaults({ rateLimit: 0 }); + const aborted = AbortSignal.abort(); + try { + // rateLimitTimestamp strictly in the past → `now < ts` is false → no wait. + // Mutant `if (true)` would call timeout({signal: aborted}) and REJECT. + const response = await fetchRateLimit( + { + url: "https://example.org/rl-gate-past", + rateLimitTimestamp: Date.now() - 5000, + rateLimit: 0, + }, + { signal: aborted }, + ); + ok( + response.ok, + "gate wrongly entered the wait branch (if(true) or aborted signal)", + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +test(`${variant}: fetchRateLimit uses strict < (not <=) at the now===timestamp boundary`, async (_t) => { + const { fetchRateLimit } = await import("@datastream/fetch"); + const originalFetch = global.fetch; + const originalNow = Date.now; + const fixed = 1_000_000_000_000; + Date.now = () => fixed; + global.fetch = async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + fetchSetDefaults({ rateLimit: 0 }); + const aborted = AbortSignal.abort(); + try { + // now === rateLimitTimestamp exactly. Original `<` → false → no wait. + // Mutant `<=` → true → timeout({signal: aborted}) → REJECT. + const response = await fetchRateLimit( + { + url: "https://example.org/rl-gate-equal", + rateLimitTimestamp: fixed, + rateLimit: 0, + }, + { signal: aborted }, + ); + ok( + response.ok, + "gate entered wait branch at now===timestamp boundary (<= mutant)", + ); + } finally { + Date.now = originalNow; + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** redactUrl: username must become [REDACTED] (or %5BREDACTED%5D) not empty string *** // +test(`${variant}: redactUrl replaces username with [REDACTED] not empty string`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => + new Response("", { status: 404, statusText: "Not Found" }); + fetchSetDefaults({}); + const { fetchRateLimit } = await import("@datastream/fetch"); + try { + // NO query string so only username is tested (query-string [REDACTED] would mask the mutation) + await fetchRateLimit({ url: "https://adminuser@example.org/secret-u" }); + throw new Error("Should have thrown"); + } catch (e) { + // URL encodes [REDACTED] to %5BREDACTED%5D for username; empty string mutant produces https://@... + const causeUrl = e.cause.url; + // Real code: https://%5BREDACTED%5D@example.org/... — mutant: https://@example.org/... + strictEqual( + causeUrl.includes("adminuser"), + false, + `cause.url should not expose username, got: ${causeUrl}`, + ); + // Must have some REDACTED marker; empty-string mutant has no REDACTED at all + const hasAnyRedacted = + causeUrl.includes("REDACTED") || + causeUrl.includes("%5B") || + causeUrl.includes("%5BREDACTED"); + strictEqual( + hasAnyRedacted, + true, + `cause.url should have REDACTED marker (not empty username), got: ${causeUrl}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** redactUrl: password must become [REDACTED] (or %5BREDACTED%5D) not empty string *** // +test(`${variant}: redactUrl replaces password with [REDACTED] not empty string`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => + new Response("", { status: 404, statusText: "Not Found" }); + fetchSetDefaults({}); + const { fetchRateLimit } = await import("@datastream/fetch"); + try { + // URL with ONLY password (no username) so only the password mutation is tested + // Real code: url.password="[REDACTED]" → %5BREDACTED%5D in URL; mutant: "" → no REDACTED + await fetchRateLimit({ url: "https://:mysecretpw@example.org/path-p" }); + throw new Error("Should have thrown"); + } catch (e) { + const causeUrl = e.cause.url; + strictEqual( + causeUrl.includes("mysecretpw"), + false, + `cause.url should not expose password, got: ${causeUrl}`, + ); + // Real: %5BREDACTED%5D for password; empty-string mutant: https://user:@... (no REDACTED) + const hasAnyRedacted = + causeUrl.includes("REDACTED") || + causeUrl.includes("%5B") || + causeUrl.includes("%5BREDACTED"); + strictEqual( + hasAnyRedacted, + true, + `cause.url should have REDACTED marker (not empty password), got: ${causeUrl}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** rateLimit: now < timestamp – timestamp equal to now must NOT trigger a wait *** // +test(`${variant}: fetchRateLimit does not delay when rateLimitTimestamp equals current time`, async (_t) => { + const { fetchRateLimit } = await import("@datastream/fetch"); + const originalFetch = global.fetch; + global.fetch = async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + fetchSetDefaults({ rateLimit: 0 }); + try { + const exactNow = Date.now(); + const start = Date.now(); + await fetchRateLimit({ + url: "https://example.org/rl-equal-now", + rateLimitTimestamp: exactNow, + rateLimit: 0, + }); + const elapsed = Date.now() - start; + ok( + elapsed < 200, + `Expected no delay when rateLimitTimestamp==now, got ${elapsed}ms`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** manageRedirects: explicit redirect:error bypasses redirect loop *** // +// When callerRedirect="error", manageRedirects=false and the redirect-management block is skipped. +// If mutant changes "if (manageRedirects)" → "if (true)", the block is ALWAYS entered. +// Difference: with a 302 response, real code returns 302 (not ok, throws error). +// Mutant: enters redirect loop, follows to target, may return 200 (no error). +test(`${variant}: fetchRateLimit with redirect:error returns 302 not following (manageRedirects false)`, async (_t) => { + const originalFetch = global.fetch; + let callCount = 0; + global.fetch = async (_url) => { + callCount++; + if (callCount === 1) { + // Return 302 to same-origin target + return new Response("", { + status: 302, + headers: new Headers({ + Location: "https://example.org/redirect-final", + }), + }); + } + // If a second call is made (mutant follows redirect), return 200 + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ + url: "https://example.org/redirect-error-opt3", + redirect: "error", + }); + // Mutant (if true): follows redirect, returns 200 → no throw → reaches here (bad!) + throw new Error("MANAGETEST_WRONGPATH"); + } catch (e) { + // Real code: throws because 302 is not ok (redirect not followed, status=302 in cause) + // Mutant: throws "MANAGETEST_WRONGPATH" because it followed the redirect and got 200 (no error) + ok( + !e.message.includes("MANAGETEST_WRONGPATH"), + `Mutant incorrectly followed redirect; error: ${e.message}`, + ); + strictEqual( + e.cause?.status, + 302, + `Expected cause.status=302 from 302 response, got: ${e.cause?.status}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** redirectCount > maxRedirects: exactly 20 redirects must succeed *** // +test(`${variant}: fetchRateLimit allows exactly 20 same-origin redirects (> not >=)`, async (_t) => { + const originalFetch = global.fetch; + let callCount = 0; + global.fetch = async (url) => { + callCount++; + if (url.startsWith("https://example.org/exact20-redir-")) { + const n = Number.parseInt(url.split("-").pop(), 10); + if (n < 20) { + return new Response("", { + status: 302, + headers: new Headers({ + Location: `https://example.org/exact20-redir-${n + 1}`, + }), + }); + } + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + const response = await fetchRateLimit({ + url: "https://example.org/exact20-redir-0", + }); + ok(response.ok, "Expected success after exactly 20 same-origin redirects"); + strictEqual( + callCount, + 21, + `Expected 21 fetch calls (20 redirects + final), got ${callCount}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** invalid redirect Location error: message and cause must not be empty *** // +// http://[invalid actually throws "Invalid URL" in new URL(loc, base) — unlike relative URLs that +// parse with null origin and hit the cross-origin check first. +test(`${variant}: fetchRateLimit invalid Location error message contains "invalid redirect Location"`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/bad-loc-msg2") { + return new Response("", { + status: 302, + headers: new Headers({ Location: "http://[invalid" }), + }); + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/bad-loc-msg2" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("invalid redirect Location"), + `Expected 'invalid redirect Location' in message, got: ${e.message}`, + ); + ok(e.cause !== undefined, "Expected cause object"); + strictEqual( + e.cause.status, + 302, + `Expected status 302, got: ${e.cause.status}`, + ); + strictEqual(typeof e.cause.url, "string"); + strictEqual(typeof e.cause.method, "string"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +test(`${variant}: fetchRateLimit invalid Location cause has populated status url method (not empty object)`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/bad-loc-cause2") { + return new Response("", { + status: 307, + headers: new Headers({ Location: "https://[incomplete" }), + }); + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/bad-loc-cause2" }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.cause !== undefined, "Expected cause object"); + strictEqual( + e.cause.status, + 307, + `Expected status 307, got: ${e.cause.status}`, + ); + strictEqual( + e.cause.method, + "GET", + `Expected method GET, got: ${e.cause.method}`, + ); + ok( + typeof e.cause.url === "string" && e.cause.url.length > 0, + `Expected non-empty cause.url, got: ${e.cause.url}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** response.body?.cancel() – must not throw when body is null *** // +test(`${variant}: fetchRateLimit handles null response body on 4xx error path`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => { + const r = new Response("", { status: 404, statusText: "Not Found" }); + Object.defineProperty(r, "body", { value: null, configurable: true }); + return r; + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/null-body-4xx" }); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.cause.status, 404); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +test(`${variant}: fetchRateLimit handles null response body on 429 retry path (before retry not max)`, async (_t) => { + // retryMaxCount: 3, first call returns 429 with null body → retryCount=1 < 3 → RETRY path (line 291) + // NOT the max-retries path (line 279). The cancel at line 291 must handle null body gracefully. + const originalFetch = global.fetch; + let callCount = 0; + global.fetch = async () => { + callCount++; + if (callCount === 1) { + const r = new Response("", { + status: 429, + statusText: "Too Many Requests", + }); + Object.defineProperty(r, "body", { value: null, configurable: true }); + return r; + } + // 2nd call: success + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({ rateLimit: 0 }); + try { + const response = await fetchRateLimit({ + url: "https://example.org/null-body-429-retry", + rateLimit: 0, + retryMaxCount: 3, + }); + ok(response.ok, "Expected success after retry with null-body 429"); + strictEqual(callCount, 2, `Expected 2 calls (1 retry), got ${callCount}`); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +test(`${variant}: fetchRateLimit handles null response body on cross-origin redirect`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/null-body-cors-redir") { + const r = new Response("", { + status: 302, + headers: new Headers({ Location: "http://169.254.169.254/meta" }), + }); + Object.defineProperty(r, "body", { value: null, configurable: true }); + return r; + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/null-body-cors-redir" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("redirect") || e.message.includes("origin"), + `Expected redirect/origin error, got: ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +test(`${variant}: fetchRateLimit handles null response body on max-redirect exceeded`, async (_t) => { + const originalFetch = global.fetch; + let n = 0; + global.fetch = async () => { + n++; + const r = new Response("", { + status: 301, + headers: new Headers({ + Location: `https://example.org/null-max-redir-${n}`, + }), + }); + Object.defineProperty(r, "body", { value: null, configurable: true }); + return r; + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/null-max-redir-0" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("exceeded") || e.message.includes("redirect"), + `Expected max redirect error, got: ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +test(`${variant}: fetchRateLimit handles null response body on invalid redirect Location`, async (_t) => { + // http://[invalid actually throws "Invalid URL" triggering the catch block at line 241 + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/null-body-bad-loc") { + const r = new Response("", { + status: 301, + headers: new Headers({ Location: "http://[invalid" }), + }); + Object.defineProperty(r, "body", { value: null, configurable: true }); + return r; + } + return originalFetch(url); + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({}); + try { + await fetchRateLimit({ url: "https://example.org/null-body-bad-loc" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("invalid redirect Location"), + `Expected "invalid redirect Location" in message, got: ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** Retry-After || fallback: 0 Retry-After should use 1000ms fallback via || *** // +test(`${variant}: fetchResponseStream uses 1000ms fallback when Retry-After is "0" (0||1000 not 0&&1000)`, async (_t) => { + // Number.parseInt("0", 10) * 1000 = 0, 0 || 1000 = 1000 (real) + // Mutant (&&): 0 && 1000 = 0 → near-zero wait + const originalFetch = global.fetch; + let callCount = 0; + const callTimes = []; + global.fetch = (url) => { + callCount++; + callTimes.push(Date.now()); + if (callCount === 1 && url === "https://example.org/retry-after-zero") { + return Promise.resolve( + new Response("", { + status: 429, + statusText: "Too Many Requests", + headers: new Headers({ "Retry-After": "0" }), + }), + ); + } + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }), + ); + }; + fetchSetDefaults({ rateLimit: 0 }); + try { + const stream = fetchResponseStream([ + { + url: "https://example.org/retry-after-zero", + rateLimit: 0, + retryMaxCount: 2, + dataPath: "", + }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + ok( + callTimes[1] - callTimes[0] >= 900, + `Expected ~1000ms wait from 0||1000 fallback, got ${callTimes[1] - callTimes[0]}ms`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** backoff: 2 ** (retryCount-1) not 2 / (retryCount-1) *** // +test(`${variant}: fetchResponseStream second retry base wait is ~2000ms (2**1=2 not 2/1)`, async (_t) => { + // retryCount=2: base = 1000 * 2**(2-1) = 2000ms (real) + // mutant (/): base = 1000 / 2**(2-1) = 500ms + // With Math.random=1.0: backoffMs = 1.0 * base + const originalFetch = global.fetch; + let callCount = 0; + const callTimes = []; + global.fetch = (url) => { + callCount++; + callTimes.push(Date.now()); + if (callCount <= 2 && url === "https://example.org/exp-backoff-det") { + return Promise.resolve( + new Response("", { status: 429, statusText: "Too Many Requests" }), + ); + } + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }), + ); + }; + const origRandom = Math.random; + Math.random = () => 1.0; + fetchSetDefaults({ rateLimit: 0 }); + try { + const stream = fetchResponseStream([ + { + url: "https://example.org/exp-backoff-det", + rateLimit: 0, + retryMaxCount: 10, + dataPath: "", + }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + // callTimes[2] - callTimes[1] = wait after retryCount=2 + // Real: 1.0 * 2000 = 2000ms; Mutant (2/): 1.0 * 500 = 500ms + ok( + callTimes[2] - callTimes[1] >= 1800, + `Expected ~2000ms for retryCount=2 (2**1*1000), got ${callTimes[2] - callTimes[1]}ms`, + ); + } finally { + Math.random = origRandom; + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** backoffMs: Math.random() * baseMs not Math.random() / baseMs *** // +test(`${variant}: fetchResponseStream jitter is Math.random()*baseMs not divided (random=1.0 test)`, async (_t) => { + // Math.random=1.0: backoffMs = 1.0 * 1000 = 1000ms (real) + // Mutant (/): 1.0 / 1000 = 0.001ms → essentially no wait + const originalFetch = global.fetch; + let callCount = 0; + const callTimes = []; + global.fetch = (url) => { + callCount++; + callTimes.push(Date.now()); + if (callCount === 1 && url === "https://example.org/jitter-mult") { + return Promise.resolve( + new Response("", { status: 429, statusText: "Too Many Requests" }), + ); + } + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }), + ); + }; + const origRandom = Math.random; + Math.random = () => 1.0; + fetchSetDefaults({ rateLimit: 0 }); + try { + const stream = fetchResponseStream([ + { + url: "https://example.org/jitter-mult", + rateLimit: 0, + retryMaxCount: 10, + dataPath: "", + }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + ok( + callTimes[1] - callTimes[0] >= 900, + `Expected ~1000ms jitter (random=1.0 * 1000ms), got ${callTimes[1] - callTimes[0]}ms`, + ); + } finally { + Math.random = origRandom; + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** pickPath: default parameter "" vs "Stryker was here!" *** // +test(`${variant}: fetchResponseStream with no dataPath yields whole body (pickPath default "" not "Stryker was here!")`, async (_t) => { + // When dataPath is undefined (not set), pickPath(body, undefined) uses default path="". + // Real: path="" → returns body; Mutant: path="Stryker was here!" → body["Stryker was here!"] = undefined + const originalFetch = global.fetch; + global.fetch = async () => + new Response(JSON.stringify({ myKey: 42 }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + fetchSetDefaults({ dataPath: undefined }); + try { + // No dataPath in options → uses default undefined → pickPath uses default "" + const stream = fetchResponseStream([ + { url: "https://example.org/pickpath-default" }, + ]); + const output = await streamToArray(stream); + // Real: output is [{myKey:42}] (whole body); Mutant: output is [undefined] (body["Stryker was here!"]) + deepStrictEqual(output, [{ myKey: 42 }]); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** redactUrl catch block: must return "[INVALID URL]" not empty string or undefined *** // +test(`${variant}: fetchRateLimit error cause.url is [INVALID URL] when URL is completely unparseable`, async (_t) => { + // Passing an invalid URL string to fetchRateLimit causes redactUrl to throw in new URL(...) + // and return "[INVALID URL]" from the catch block. + // Mutant "return """: cause.url = "" (empty string) + // Mutant "} catch {}": catch block is empty, function returns undefined → cause.url = undefined + const originalFetch = global.fetch; + global.fetch = async () => + new Response("", { status: 404, statusText: "Not Found" }); + fetchSetDefaults({}); + const { fetchRateLimit } = await import("@datastream/fetch"); + try { + await fetchRateLimit({ url: "not-a-valid-url-no-scheme" }); + throw new Error("Should have thrown"); + } catch (e) { + const causeUrl = e.cause?.url; + strictEqual( + causeUrl, + "[INVALID URL]", + `Expected cause.url to be "[INVALID URL]", got: ${causeUrl}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({}); + } +}); + +// *** response.body?.cancel() on 429 max-retries path (line 279) *** // +test(`${variant}: fetchRateLimit handles null response body on 429 max-retries path`, async (_t) => { + // retryMaxCount: 1, first call returns 429 with null body → retryCount=1 >= retryMaxCount=1 → MAX RETRIES path (line 279) + const originalFetch = global.fetch; + global.fetch = async () => { + const r = new Response("", { + status: 429, + statusText: "Too Many Requests", + }); + Object.defineProperty(r, "body", { value: null, configurable: true }); + return r; + }; + const { fetchRateLimit } = await import("@datastream/fetch"); + fetchSetDefaults({ rateLimit: 0 }); + try { + await fetchRateLimit({ + url: "https://example.org/null-body-429-max", + rateLimit: 0, + retryMaxCount: 1, + }); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual( + e.cause.status, + 429, + `Expected cause.status=429, got: ${e.cause?.status}`, + ); + ok( + e.message.includes("max retries"), + `Expected "max retries" in message, got: ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); diff --git a/packages/fetch/package.json b/packages/fetch/package.json index 2b4145e..a2298b9 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/fetch", - "version": "0.5.0", + "version": "0.6.1", "description": "HTTP fetch-based readable and writable streams with pagination and rate limiting", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,6 +61,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/file/README.md b/packages/file/README.md index da39626..18c4b71 100644 --- a/packages/file/README.md +++ b/packages/file/README.md @@ -1,6 +1,6 @@

<datastream> `file`

- datastream logo + datastream logo

File system streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/file/index.node.js b/packages/file/index.node.js index 66603c5..fda6ee9 100644 --- a/packages/file/index.node.js +++ b/packages/file/index.node.js @@ -33,12 +33,14 @@ export const fileWriteStream = ( if (basePath != null) { const fd = openSync( path, - constants.O_WRONLY | - constants.O_CREAT | - constants.O_TRUNC | - constants.O_NOFOLLOW, + constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, ); - return createWriteStream(null, { ...makeOptions(streamOptions), fd }); + // `flags` is ignored by createWriteStream when an `fd` is supplied (the + // file is already open), so it is omitted here. + return createWriteStream(null, { + ...makeOptions(streamOptions), + fd, + }); } return createWriteStream(path, makeOptions(streamOptions)); }; diff --git a/packages/file/index.test.js b/packages/file/index.test.js index 196cfa8..7b5a22a 100644 --- a/packages/file/index.test.js +++ b/packages/file/index.test.js @@ -1,7 +1,13 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT import { deepStrictEqual, strictEqual, throws } from "node:assert"; -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -38,6 +44,87 @@ test(`${variant}: fileReadStream should read a file`, async () => { strictEqual(output, testContent); }); +// *** fileReadStream with basePath - success case *** // +test(`${variant}: fileReadStream with basePath should read a file`, async () => { + const stream = fileReadStream({ path: testFile, basePath: testDir }); + const output = await streamToString(stream); + strictEqual(output, testContent); +}); + +// *** fileReadStream without basePath should follow symlinks *** // +test(`${variant}: fileReadStream without basePath can read through symlink`, async () => { + const linkPath = join(testDir, "sym-no-base.csv"); + try { + symlinkSync(testFile, linkPath); + } catch (_) { + // already exists + } + // Without basePath, no O_NOFOLLOW - symlinks are followed (read succeeds) + const stream = fileReadStream({ path: linkPath }); + const output = await streamToString(stream); + strictEqual(output, testContent); +}); + +// *** fileWriteStream without basePath - follows symlinks *** // +test(`${variant}: fileWriteStream without basePath can write through symlink`, async () => { + const outFile = join(testDir, "real-target.csv"); + writeFileSync(outFile, ""); + const writeLinkPath = join(testDir, "write-sym-no-base.csv"); + try { + symlinkSync(outFile, writeLinkPath); + } catch (_) { + // already exists + } + // Without basePath: no O_NOFOLLOW - symlinks are followed (write succeeds) + const stream = fileWriteStream({ path: writeLinkPath }); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + stream.write(Buffer.from("via-symlink")); + stream.end(); + }); + strictEqual(readFileSync(outFile, "utf8"), "via-symlink"); +}); + +// *** fileWriteStream with basePath - success case *** // +test(`${variant}: fileWriteStream with basePath should write a new file`, async () => { + const outFile = join(testDir, "written.csv"); + const stream = fileWriteStream({ path: outFile, basePath: testDir }); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + stream.write(Buffer.from("written content")); + stream.end(); + }); + strictEqual(readFileSync(outFile, "utf8"), "written content"); +}); + +// *** fileReadStream with basePath - streamOptions passed through *** // +test(`${variant}: fileReadStream with basePath passes streamOptions`, async () => { + const stream = fileReadStream( + { path: testFile, basePath: testDir }, + { highWaterMark: 1 }, + ); + const output = await streamToString(stream); + strictEqual(output, testContent); +}); + +// *** fileWriteStream with basePath - streamOptions passed through *** // +test(`${variant}: fileWriteStream with basePath passes streamOptions`, async () => { + const outFile = join(testDir, "opts.csv"); + const stream = fileWriteStream( + { path: outFile, basePath: testDir }, + { highWaterMark: 1 }, + ); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + stream.write(Buffer.from("opts")); + stream.end(); + }); + strictEqual(readFileSync(outFile, "utf8"), "opts"); +}); + // *** Path traversal *** // test(`${variant}: fileReadStream should reject path traversal`, () => { throws(() => fileReadStream({ path: "/etc/passwd", basePath: testDir }), { @@ -71,15 +158,44 @@ test(`${variant}: fileReadStream should reject sibling-prefix bypass`, () => { ); }); +// *** Path traversal - basePath itself (rel === '') *** // +test(`${variant}: fileReadStream rejects when path equals basePath`, () => { + throws(() => fileReadStream({ path: testDir, basePath: testDir }), { + message: "Path traversal detected", + }); +}); + +test(`${variant}: fileWriteStream rejects when path equals basePath`, () => { + throws(() => fileWriteStream({ path: testDir, basePath: testDir }), { + message: "Path traversal detected", + }); +}); + // *** Symlink rejection *** // test(`${variant}: fileReadStream should reject symlinks`, () => { const linkPath = join(testDir, "link.csv"); - symlinkSync(testFile, linkPath); + try { + symlinkSync(testFile, linkPath); + } catch (_) { + // already exists + } throws(() => fileReadStream({ path: linkPath, basePath: testDir }), { message: "Symbolic links are not allowed", }); }); +test(`${variant}: fileWriteStream should reject symlinks`, () => { + const linkPath = join(testDir, "write-link.csv"); + try { + symlinkSync(testFile, linkPath); + } catch (_) { + // already exists + } + throws(() => fileWriteStream({ path: linkPath, basePath: testDir }), { + message: "Symbolic links are not allowed", + }); +}); + // *** Extension enforcement *** // test(`${variant}: fileReadStream should accept matching extension`, async () => { const stream = fileReadStream({ path: testFile, types: csvTypes }); @@ -108,12 +224,82 @@ test(`${variant}: fileWriteStream should reject non-matching extension`, () => { ); }); +// *** ENOENT is allowed (new file for write) *** // +test(`${variant}: fileWriteStream with basePath allows new file (ENOENT ok)`, async () => { + const outFile = join(testDir, "brand-new.csv"); + const stream = fileWriteStream({ path: outFile, basePath: testDir }); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + stream.write(Buffer.from("new")); + stream.end(); + }); + strictEqual(readFileSync(outFile, "utf8"), "new"); +}); + +// *** Path not found for non-ENOENT stat errors *** // +test(`${variant}: fileReadStream throws Path not found for non-ENOENT stat error`, () => { + // testFile is a regular file, not a directory - accessing subpath causes ENOTDIR + const notDir = join(testFile, "inside.csv"); + throws(() => fileReadStream({ path: notDir, basePath: testDir }), { + message: "Path not found", + }); +}); + +// *** Path not found error has a cause *** // +test(`${variant}: fileReadStream Path not found error carries cause`, () => { + const notDir = join(testFile, "inside.csv"); + throws( + () => fileReadStream({ path: notDir, basePath: testDir }), + (e) => { + strictEqual(e.message, "Path not found"); + strictEqual(typeof e.cause, "object"); + return true; + }, + ); +}); + // *** No basePath = no path checks *** // test(`${variant}: fileReadStream allows any path without basePath`, async () => { const stream = fileReadStream({ path: testFile }); await streamToString(stream); }); +// *** fileWriteStream with basePath: failed constructor leaves existing file intact *** // +test(`${variant}: fileWriteStream with basePath preserves existing file when stream constructor fails`, () => { + const guardFile = join(testDir, "guard.csv"); + const originalContent = "important,data\n1,2,3\n"; + writeFileSync(guardFile, originalContent); + // {start:-5} is an invalid option that makes createWriteStream throw synchronously + throws( + () => + fileWriteStream({ path: guardFile, basePath: testDir }, { start: -5 }), + (e) => typeof e === "object" && e !== null, + ); + // The file must still contain its original content — not be wiped to zero bytes + strictEqual(readFileSync(guardFile, "utf8"), originalContent); +}); + +// *** fd-based streams when basePath is provided *** // +// When basePath is set, fileReadStream must use openSync+O_NOFOLLOW and pass the +// resulting fd to createReadStream (not the file path). The returned stream's .fd +// property is already a number in that case, whereas a path-based stream has .fd===null +// until the OS opens it. Mutants that skip the if(basePath!=null) block would produce +// a path-based stream whose .fd is null at construction time. +test(`${variant}: fileReadStream with basePath returns an fd-based stream`, () => { + const stream = fileReadStream({ path: testFile, basePath: testDir }); + strictEqual(typeof stream.fd, "number"); + stream.destroy(); +}); + +test(`${variant}: fileWriteStream with basePath returns an fd-based stream`, () => { + const outFile = join(testDir, "fd-write-check.csv"); + writeFileSync(outFile, ""); + const stream = fileWriteStream({ path: outFile, basePath: testDir }); + strictEqual(typeof stream.fd, "number"); + stream.destroy(); +}); + // *** default export *** // test(`${variant}: default export should include all stream functions`, () => { deepStrictEqual(Object.keys(fileDefault).sort(), [ diff --git a/packages/file/index.web.js b/packages/file/index.web.js index fbdc19c..95cd95a 100644 --- a/packages/file/index.web.js +++ b/packages/file/index.web.js @@ -2,9 +2,26 @@ // SPDX-License-Identifier: MIT import { createReadableStream } from "@datastream/core"; +const enforceType = (name, types = []) => { + if (!types.length) return; + const dotIdx = name.lastIndexOf("."); + const pathExt = dotIdx >= 0 ? name.slice(dotIdx) : ""; + for (const type of types) { + for (const mime in type.accept) { + for (const ext of type.accept[mime]) { + if (pathExt === ext) { + return; + } + } + } + } + throw new Error("Invalid extension"); +}; + export const fileReadStream = async ({ types }, _streamOptions = {}) => { const [fileHandle] = await window.showOpenFilePicker({ types }); const fileData = await fileHandle.getFile(); + enforceType(fileData.name, types); return createReadableStream(fileData); }; @@ -13,6 +30,7 @@ export const fileWriteStream = async ({ path, types }, _streamOptions = {}) => { suggestedName: path, types, }); + enforceType(fileHandle.name, types); return fileHandle.createWritable(); }; diff --git a/packages/file/package.json b/packages/file/package.json index ddc35a0..8184c18 100644 --- a/packages/file/package.json +++ b/packages/file/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/file", - "version": "0.5.0", + "version": "0.6.1", "description": "File system readable and writable streams with extension type enforcement", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,6 +61,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/indexeddb/README.md b/packages/indexeddb/README.md index 826d3ba..a837b0b 100644 --- a/packages/indexeddb/README.md +++ b/packages/indexeddb/README.md @@ -1,6 +1,6 @@

<datastream> `indexeddb`

- datastream logo + datastream logo

IndexedDB streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/indexeddb/index.perf.js b/packages/indexeddb/index.perf.js new file mode 100644 index 0000000..1e47cb1 --- /dev/null +++ b/packages/indexeddb/index.perf.js @@ -0,0 +1,108 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import test from "node:test"; +import { createReadableStream, pipeline } from "@datastream/core"; +import { Bench } from "tinybench"; +// Under `node --test` the package entry (index.node.mjs) throws "Not supported" +// because there is no IndexedDB in Node. Per project convention (see +// index.test.js), the real web implementation is exercised directly with an +// `idb`-shaped mock so the streams do real work instead of throwing. +import { indexedDBReadStream, indexedDBWriteStream } from "./index.web.js"; + +// -- Mock idb (mirrors the shapes used in index.test.js) -- + +// Stores/indexes return async iterators of IDBCursor-shaped objects ({ value }). +// `iterate(key)` filters by `name`, matching the real index semantics. +const makeCursors = (records) => ({ + async *[Symbol.asyncIterator]() { + for (const record of records) { + yield { value: record }; + } + }, +}); +const makeStore = (records) => ({ + iterate: () => makeCursors(records), + index: (_name) => ({ + iterate: (key) => makeCursors(records.filter((r) => r.name === key)), + }), + add: async (record) => { + records.push(record); + }, +}); +const makeDb = (records) => ({ + transaction: (_store, _mode) => ({ + store: makeStore(records), + done: Promise.resolve(), + }), +}); + +const drain = async (stream) => { + for await (const _chunk of stream) { + // consume + } +}; + +// -- Data generators -- + +const time = Number(process.env.BENCH_TIME ?? 5_000); +const ITEMS = 10_000; + +const records = Array.from({ length: ITEMS }, (_, i) => ({ + id: i, + name: i % 2 === 0 ? "even" : "odd", + value: `value_${i}`, +})); + +// -- Tests -- + +test("perf: indexedDBReadStream", async () => { + const bench = new Bench({ name: "indexedDBReadStream", time }); + + bench.add(`${ITEMS} records`, async () => { + const stream = await indexedDBReadStream({ + db: makeDb(records), + store: "test-store", + }); + await drain(stream); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: indexedDBReadStream via index + key", async () => { + const bench = new Bench({ name: "indexedDBReadStream index", time }); + + bench.add(`${ITEMS} records, index "name"`, async () => { + const stream = await indexedDBReadStream({ + db: makeDb(records), + store: "test-store", + index: "name", + key: "even", + }); + await drain(stream); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: indexedDBWriteStream", async () => { + const bench = new Bench({ name: "indexedDBWriteStream", time }); + + bench.add(`${ITEMS} records`, async () => { + // Fresh empty store per iteration so the backing array doesn't grow + // unbounded across runs. + const writeStream = await indexedDBWriteStream({ + db: makeDb([]), + store: "test-store", + }); + await pipeline([createReadableStream(records), writeStream]); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); diff --git a/packages/indexeddb/index.test.js b/packages/indexeddb/index.test.js index f281b81..25097d8 100644 --- a/packages/indexeddb/index.test.js +++ b/packages/indexeddb/index.test.js @@ -1,6 +1,6 @@ -import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert"; import test from "node:test"; -import { +import indexeddbDefault, { indexedDBReadStream, indexedDBWriteStream, } from "@datastream/indexeddb"; @@ -111,44 +111,237 @@ if (!isBrowser) { strictEqual(e.message, "indexedDBWriteStream: Not supported"); } }); + + test(`${variant}: default export should expose readStream and writeStream`, (_t) => { + strictEqual(typeof indexeddbDefault.readStream, "function"); + strictEqual(typeof indexeddbDefault.writeStream, "function"); + // They must be the named exports (same reference). + strictEqual(indexeddbDefault.readStream, indexedDBReadStream); + strictEqual(indexeddbDefault.writeStream, indexedDBWriteStream); + }); } -// *** web variant: indexedDBReadStream with index *** // -if (variant === "webstream") { - test(`${variant}: indexedDBReadStream should use index and key when provided`, { - skip: "requires web implementation", - }, async (_t) => { - const mockCursor = { - async *[Symbol.asyncIterator]() { - yield { id: 1, name: "a" }; - yield { id: 2, name: "b" }; - }, +// *** web variant: exercise the real web implementation directly *** // +// Per project memory, `--conditions=webstream` loads the node build, so the web +// code is exercised by importing `index.web.js` directly with mocked `idb` +// objects shaped like the real library (async iterators that yield IDBCursor +// objects exposing `.value`). +// +// NOTE: `streamToArray` uses event-based stream consumption which doesn't work +// under `--test-force-exit` (used by Stryker). Use `for await` instead so the +// test Promise settles before Node forces an exit. +const readAll = async (stream) => { + const out = []; + for await (const chunk of stream) { + out.push(chunk); + } + return out; +}; +{ + const { + indexedDBReadStream: webReadStream, + indexedDBWriteStream: webWriteStream, + } = await import("./index.web.js"); + + // Build a mock that mimics idb: stores/indexes return async iterators of + // IDBCursor-shaped objects ({ value }). `iterate(key)` filters by `name`. + const makeCursors = (records) => ({ + async *[Symbol.asyncIterator]() { + for (const record of records) { + yield { value: record }; + } + }, + }); + const makeStore = (records) => ({ + iterate: () => makeCursors(records), + index: (_name) => ({ + iterate: (key) => makeCursors(records.filter((r) => r.name === key)), + }), + add: async (record) => { + records.push(record); + }, + }); + const makeDb = (records, { onTransaction } = {}) => ({ + transaction: (_store, _mode) => { + onTransaction?.(); + return { store: makeStore(records), done: Promise.resolve() }; + }, + }); + + test(`web: indexedDBReadStream yields stored records (cursor.value), not raw cursors`, async (_t) => { + const records = [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]; + const stream = await webReadStream({ + db: makeDb(records), + store: "test", + }); + + const output = await readAll(stream); + + deepStrictEqual(output.length, 2); + // Real stored records, not IDBCursor objects. + deepStrictEqual(output[0], { id: 1, name: "a" }); + deepStrictEqual(output[1], { id: 2, name: "b" }); + // Guard against the regression of emitting the wrapping cursor object. + strictEqual(output[0].value, undefined); + }); + + test(`web: indexedDBReadStream uses index + key when provided`, async (_t) => { + const records = [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "a" }, + ]; + const stream = await webReadStream({ + db: makeDb(records), + store: "test", + index: "name", + key: "a", + }); + + const output = await readAll(stream); + + // Only the two records whose name === "a" come back via the index. + strictEqual(output.length, 2); + deepStrictEqual(output[0], { id: 1, name: "a" }); + deepStrictEqual(output[1], { id: 3, name: "a" }); + }); + + test(`web: indexedDBReadStream uses the index for a falsy-but-valid key (0)`, async (_t) => { + // key === 0 is a valid IndexedDB key. The store records use name 0 vs 1. + const records = [ + { id: 1, name: 0 }, + { id: 2, name: 1 }, + { id: 3, name: 0 }, + ]; + const stream = await webReadStream({ + db: makeDb(records), + store: "test", + index: "name", + key: 0, + }); + + const output = await readAll(stream); + + // With the buggy `if (index && key)` guard, key 0 is falsy and the whole + // store (all 3) would be returned instead of the 2 name===0 records. + strictEqual(output.length, 2); + deepStrictEqual(output[0], { id: 1, name: 0 }); + deepStrictEqual(output[1], { id: 3, name: 0 }); + }); + + test(`web: indexedDBReadStream uses the index for an empty-string key ("")`, async (_t) => { + const records = [ + { id: 1, name: "" }, + { id: 2, name: "x" }, + ]; + const stream = await webReadStream({ + db: makeDb(records), + store: "test", + index: "name", + key: "", + }); + + const output = await readAll(stream); + + strictEqual(output.length, 1); + deepStrictEqual(output[0], { id: 1, name: "" }); + }); + + test(`web: indexedDBReadStream removes the abort listener on normal completion`, async (_t) => { + // Spy on a real AbortController's listener registration to assert the + // wrapper's own "abort" listener is removed once iteration settles, so a + // shared signal does not accumulate one listener per constructed stream. + const controller = new AbortController(); + const { signal } = controller; + let listeners = 0; + const realAdd = signal.addEventListener.bind(signal); + const realRemove = signal.removeEventListener.bind(signal); + signal.addEventListener = (...args) => { + listeners += 1; + return realAdd(...args); }; - const mockIndex = { - iterate: (_key) => mockCursor, + signal.removeEventListener = (...args) => { + listeners -= 1; + return realRemove(...args); }; - const mockStore = { - index: (_name) => mockIndex, - [Symbol.asyncIterator]: async function* () { - yield { id: 1, name: "a" }; - yield { id: 2, name: "b" }; - yield { id: 3, name: "c" }; + const records = [{ id: 1, name: "a" }]; + + const stream = await webReadStream( + { db: makeDb(records), store: "test" }, + { signal }, + ); + await readAll(stream); + // Allow the underlying stream's terminal "close" handlers to run so any + // listener teardown (wrapper + core) has settled before asserting. + await new Promise((resolve) => setImmediate(resolve)); + + // On normal completion every registered listener must be removed (the + // wrapper's "abort" listener leaked here before the fix). + strictEqual(listeners, 0); + }); + + test(`web: indexedDBReadStream stops iterating once the signal aborts`, async (_t) => { + const controller = new AbortController(); + let produced = 0; + const cursors = { + async *[Symbol.asyncIterator]() { + for (let id = 1; id <= 5; id++) { + produced += 1; + // Abort partway through to verify iteration breaks early. + if (id === 2) controller.abort(); + yield { value: { id } }; + } }, }; - const mockDb = { - transaction: (_store) => ({ store: mockStore }), + const db = { + transaction: () => ({ + store: { iterate: () => cursors, index: () => ({}) }, + done: Promise.resolve(), + }), }; - const stream = await indexedDBReadStream({ - db: mockDb, - store: "test", - index: "name", - key: "a", + const stream = await webReadStream( + { db, store: "test" }, + { signal: controller.signal }, + ); + + // Aborting must stop the stream rather than draining all 5 records. + await rejects( + async () => { + for await (const _ of stream) { + // drain + } + }, + { name: "AbortError" }, + ); + ok(produced < 5, `expected early stop, produced ${produced}`); + }); + + test(`web: indexedDBWriteStream opens a fresh transaction per chunk (no auto-commit reuse)`, async (_t) => { + // A shared transaction would auto-commit between async chunks and throw + // TransactionInactiveError. Assert each write gets its own transaction. + let transactions = 0; + const records = [ + { id: 1, value: "a" }, + { id: 2, value: "b" }, + { id: 3, value: "c" }, + ]; + const db = makeDb([], { + onTransaction: () => { + transactions += 1; + }, }); - const { streamToArray } = await import("@datastream/core"); - const output = await streamToArray(stream); - // Should return filtered results (2 items from index), not all 3 from store - strictEqual(output.length, 2); + const writeStream = await webWriteStream({ db, store: "test" }); + for (const record of records) { + writeStream.write(record); + } + writeStream.end(); + await new Promise((resolve) => writeStream.on("finish", resolve)); + + strictEqual(transactions, records.length); }); } diff --git a/packages/indexeddb/index.web.js b/packages/indexeddb/index.web.js index 33c813e..1000a5e 100644 --- a/packages/indexeddb/index.web.js +++ b/packages/indexeddb/index.web.js @@ -9,25 +9,61 @@ export const indexedDBReadStream = async ( { db, store, index, key }, streamOptions = {}, ) => { - let input = db.transaction(store).store; - if (index && key) { - input = input.index(index).iterate(key); + const tx = db.transaction(store); + let source = tx.store; + // A falsy-but-valid index key (0 or "") must still select the index. Only + // treat the index as absent when it is null/undefined, and only treat the + // key as omitted when it is strictly undefined (null is a valid key range). + if (index != null && key !== undefined) { + source = source.index(index).iterate(key); + } else { + source = source.iterate(); } - return createReadableStream(input, streamOptions); + const { signal } = streamOptions; + // idb's async iterators yield IDBCursor objects, not the stored records. + // Map each cursor to its `.value` before handing the source to the core + // stream factory; otherwise consumers receive raw cursors instead of data. + // Honor an AbortSignal by stopping iteration when it fires, and ALWAYS + // remove the listener once iteration settles so a shared signal does not + // accumulate one listener per stream (leak on normal completion). + const records = (async function* () { + let aborted = signal?.aborted ?? false; + let onAbort; + if (signal && !aborted) { + onAbort = () => { + aborted = true; + }; + signal.addEventListener("abort", onAbort, { once: true }); + } + try { + for await (const cursor of source) { + if (aborted) break; + yield cursor.value; + } + } finally { + if (signal && onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + } + })(); + return createReadableStream(records, streamOptions); }; export const indexedDBWriteStream = async ( { db, store }, streamOptions = {}, ) => { - const tx = db.transaction(store, "readwrite"); + // A single shared transaction auto-commits as soon as it goes idle between + // async chunks, throwing TransactionInactiveError on the next write. Open a + // fresh readwrite transaction per chunk so each add() runs inside an active + // transaction, and await its completion to preserve ordering/backpressure. const write = async (chunk) => { + const tx = db.transaction(store, "readwrite"); await tx.store.add(chunk); - }; - const final = async () => { await tx.done; }; - return createWritableStream(write, final, streamOptions); + return createWritableStream(write, streamOptions); }; export default { diff --git a/packages/indexeddb/package.json b/packages/indexeddb/package.json index b0c0a59..6e52252 100644 --- a/packages/indexeddb/package.json +++ b/packages/indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/indexeddb", - "version": "0.5.0", + "version": "0.6.1", "description": "IndexedDB readable and writable streams for browser storage", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,7 +61,7 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0", + "@datastream/core": "0.6.1", "idb": "8.0.3" } } diff --git a/packages/ipfs/README.md b/packages/ipfs/README.md index 6c8002d..0a912ba 100644 --- a/packages/ipfs/README.md +++ b/packages/ipfs/README.md @@ -1,6 +1,6 @@

<datastream> `ipfs`

- datastream logo + datastream logo

IPFS streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/ipfs/index.d.ts b/packages/ipfs/index.d.ts index 8dcd7af..97bd90f 100644 --- a/packages/ipfs/index.d.ts +++ b/packages/ipfs/index.d.ts @@ -29,6 +29,12 @@ export function ipfsAddStream( ): Promise< DatastreamWritable & { result: () => StreamResult; + /** + * Diagnostic: the error (if any) that teardown injected into the source + * when the stream was destroyed/aborted before completion. Stays + * `undefined` after a clean completion. + */ + injectedError: () => Error | undefined; } >; diff --git a/packages/ipfs/index.js b/packages/ipfs/index.js index e82382b..a13e041 100644 --- a/packages/ipfs/index.js +++ b/packages/ipfs/index.js @@ -1,32 +1,167 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT -import { createPassThroughStream } from "@datastream/core"; +import { + createPassThroughStream, + createReadableStream, +} from "@datastream/core"; -export const ipfsGetStream = async ({ node, cid }, _streamOptions = {}) => { - return node.get(cid); +export const ipfsGetStream = async ({ node, cid }, streamOptions = {}) => { + const source = await node.get(cid); + return createReadableStream(source, streamOptions); }; export const ipfsAddStream = async ( { node, resultKey } = {}, streamOptions = {}, ) => { - const chunks = []; let cid; - const stream = createPassThroughStream( - (chunk) => { - chunks.push(chunk); + // Bridge the pass-through's per-chunk callbacks into an async iterable so + // node.add consumes the stream incrementally instead of buffering it all + // in memory. A single-slot pull handshake gives real backpressure: each + // produced chunk is held until source() pulls it, so the transform's + // transform() callback only completes once node.add has consumed the chunk. + let slot; // the chunk currently waiting to be pulled, when filled === true + let filled = false; + let done = false; + let error; + // Set once node.add settles. A client may resolve without draining the + // source (it took what it needed); once that happens there is no consumer + // left to pull, so remaining chunks pass straight through without parking. + let consumerDone = false; + // Resolves when a produced chunk has been pulled by source() (back-pressure + // release for the producer). + let resolvePulled; + // Resolves when a chunk has been produced (or done/error set) for source(). + let resolveProduced; + const onProduced = () => { + resolveProduced?.(); + resolveProduced = undefined; + }; + const onPulled = () => { + resolvePulled?.(); + resolvePulled = undefined; + }; + + async function* source() { + while (true) { + if (error) throw error; + if (filled) { + const chunk = slot; + slot = undefined; + filled = false; + // Release the producer now that this chunk has been pulled. + onPulled(); + yield chunk; + continue; + } + if (done) return; + // Wait for the next chunk (or done/error) to be produced. + await new Promise((resolve) => { + resolveProduced = resolve; + }); + } + } + + // Kick off the add so it pulls from the source as chunks arrive. Wrap in + // Promise.resolve so a client whose add() returns synchronously (or any + // non-Promise thenable) is normalized — mirrors ipfsGetStream's `await + // node.get`, and avoids a cryptic "add(...).then is not a function". + const addPromise = Promise.resolve(node.add(source())).then( + (result) => { + // The consumer is finished; release any parked producer so a client + // that resolves without draining the source can't deadlock the + // transform. Do this before validating the shape so the producer is + // released even on a malformed result. + consumerDone = true; + onPulled(); + // Guard against a legacy / differently-shaped client that resolves + // without { cid }; surface a clear, actionable error instead of an + // opaque "Cannot read properties of undefined (reading 'cid')". + if (result == null || typeof result !== "object" || !("cid" in result)) { + throw new Error("node.add did not return { cid }"); + } + cid = result.cid; + }, + (e) => { + // Record the consumer failure so a still-blocked producer is released + // with the error instead of hanging. `error` is now truthy, so the + // transform's `if (error)` guard short-circuits before it ever reads + // `consumerDone`; setting consumerDone here would be dead (the + // `if (consumerDone)` branch is unreachable once error is set), so we + // deliberately leave it untouched. + error ??= e; + onPulled(); + throw e; }, + ); + // Swallow the rejection on this reference; the flush() handler awaits and + // re-throws so the pipeline still sees it, but an unobserved teardown path + // must not surface an unhandled rejection. + addPromise.catch(() => {}); + + const stream = createPassThroughStream( + (chunk) => + // Park the chunk and resolve only once source() has pulled it. This + // awaited promise is what applies backpressure to upstream. + new Promise((resolve, reject) => { + if (error) { + reject(error); + return; + } + // No consumer left to pull (node.add already settled): pass + // through immediately instead of parking forever. + if (consumerDone) { + resolve(); + return; + } + slot = chunk; + filled = true; + onProduced(); + resolvePulled = () => { + if (error) reject(error); + else resolve(); + }; + }), async () => { - const result = await node.add(chunks); - cid = result.cid; + done = true; + onProduced(); + await addPromise; }, streamOptions, ); + // If the transform errors or is destroyed (e.g. aborted, or an upstream + // pipeline error tears it down), neither transform() nor flush() will run + // again. Inject the error into source() so the generator throws and + // node.add settles instead of stranding forever on a parked promise. + // + // `done` (set by flush) means source() has already returned and no producer + // is parked, so a late teardown has nothing to wake — and, crucially, must + // not fabricate an error after a clean completion. We record whatever error a + // teardown injects so callers can confirm a successful run left none behind; + // dropping the `if (done) return` guard would record a spurious + // "ipfsAddStream destroyed" here on the close that always follows success. + let injectedError; + const teardown = (e) => { + if (done) return; + error ??= e ?? new Error("ipfsAddStream destroyed"); + injectedError = error; + // Wake both sides: source() so it throws, and any parked producer so its + // transform() callback rejects. + onProduced(); + onPulled(); + }; + stream.on("error", teardown); + stream.on("close", () => teardown()); + stream.result = () => ({ key: resultKey ?? "cid", value: cid, }); + // Diagnostic seam: the error (if any) that teardown injected into the + // source. After a clean completion this stays undefined, because teardown is + // a no-op once `done` is set; an aborted/destroyed run exposes the cause. + stream.injectedError = () => injectedError; return stream; }; diff --git a/packages/ipfs/index.test.js b/packages/ipfs/index.test.js index fc24dd7..db983d3 100644 --- a/packages/ipfs/index.test.js +++ b/packages/ipfs/index.test.js @@ -1,9 +1,10 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT -import { deepStrictEqual, strictEqual } from "node:assert"; +import { deepStrictEqual, rejects, strictEqual } from "node:assert"; import test from "node:test"; import { createReadableStream, + isReadable, pipejoin, pipeline, streamToArray, @@ -56,13 +57,55 @@ test(`${variant}: ipfsGetStream should work in a pipeline`, async () => { deepStrictEqual(output, chunks); }); +test(`${variant}: ipfsGetStream should normalize a raw async iterable into a platform stream`, async () => { + // node.get can hand back whatever the client uses (here a bare async + // generator). ipfsGetStream must normalize it to a platform stream so + // downstream pipeline behavior is consistent across environments. + const node = { + get(_cid) { + return (async function* () { + yield "x"; + yield "y"; + })(); + }, + }; + const stream = await ipfsGetStream({ node, cid: "QmIter" }); + strictEqual(isReadable(stream), true); + const output = await streamToArray(stream); + deepStrictEqual(output, ["x", "y"]); +}); + +test(`${variant}: ipfsGetStream should await an async node.get (Promise)`, async () => { + // The dominant real-world client shape: node.get is async and resolves to + // the content stream. ipfsGetStream must await it before normalizing. + const node = { + get(_cid) { + return Promise.resolve( + (async function* () { + yield "x"; + yield "y"; + })(), + ); + }, + }; + const stream = await ipfsGetStream({ node, cid: "QmAsyncGet" }); + strictEqual(isReadable(stream), true); + const output = await streamToArray(stream); + deepStrictEqual(output, ["x", "y"]); +}); + // *** ipfsAddStream *** // -test(`${variant}: ipfsAddStream should pipe data through and call node.add on flush`, async () => { +test(`${variant}: ipfsAddStream should pipe data through and call node.add`, async () => { const input = ["chunk1", "chunk2"]; - let addedData; + let receivedChunks; const node = { - async add(data) { - addedData = data; + async add(source) { + // node.add receives an async-iterable source (streamed), not a + // buffered array. + receivedChunks = []; + for await (const chunk of source) { + receivedChunks.push(chunk); + } return { cid: "QmResult123" }; }, }; @@ -71,12 +114,80 @@ test(`${variant}: ipfsAddStream should pipe data through and call node.add on fl const { key, value } = streams[1].result(); strictEqual(key, "cid"); strictEqual(value, "QmResult123"); - deepStrictEqual(addedData, input); + deepStrictEqual(receivedChunks, input); +}); + +// Kill: ConditionalExpression survivor `if (error) reject(error)` → `if (true)` in +// the resolvePulled callback. When a parked chunk is pulled with no error, the +// producer's transform callback must RESOLVE so the Transform pushes the chunk +// downstream. With the mutant it rejects with `undefined`, which the core +// Transform treats as a (falsy) non-error and therefore skips `this.push(chunk)` +// — so the passed-through output is dropped. Asserting the full passthrough +// output catches that. +test(`${variant}: ipfsAddStream should pass every chunk through to downstream`, async () => { + const input = ["chunk1", "chunk2", "chunk3"]; + const node = { + async add(source) { + for await (const _chunk of source) { + } + return { cid: "QmPassthrough" }; + }, + }; + const streams = [createReadableStream(input), await ipfsAddStream({ node })]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // The add transform is a pass-through: every input chunk must reach + // downstream in order, not be dropped. + deepStrictEqual(output, input); + deepStrictEqual(streams[1].result().value, "QmPassthrough"); +}); + +test(`${variant}: ipfsAddStream should feed node.add an async iterable, not a buffered array`, async () => { + let source; + const node = { + async add(s) { + source = s; + const chunks = []; + for await (const chunk of s) { + chunks.push(chunk); + } + return { cid: "QmStreamed" }; + }, + }; + const streams = [ + createReadableStream(["a", "b", "c"]), + await ipfsAddStream({ node }), + ]; + await pipeline(streams); + // The source must be a streamed async iterable, never a materialized Array. + strictEqual(Array.isArray(source), false); + strictEqual(typeof source[Symbol.asyncIterator], "function"); + strictEqual(streams[1].result().value, "QmStreamed"); +}); + +test(`${variant}: ipfsAddStream should propagate node.add errors through the pipeline`, async () => { + const node = { + async add(source) { + // Consume one chunk then fail, simulating an IPFS client error. + for await (const _chunk of source) { + throw new Error("add failed"); + } + }, + }; + const streams = [ + createReadableStream(["a", "b"]), + await ipfsAddStream({ node }), + ]; + await rejects(() => pipeline(streams), /add failed/); + // The cid is never set when the add rejects. + strictEqual(streams[1].result().value, undefined); }); test(`${variant}: ipfsAddStream should use custom resultKey`, async () => { const node = { - async add(_data) { + async add(source) { + for await (const _chunk of source) { + } return { cid: "QmResult123" }; }, }; @@ -91,7 +202,9 @@ test(`${variant}: ipfsAddStream should use custom resultKey`, async () => { test(`${variant}: ipfsAddStream result should be collected by pipeline`, async () => { const node = { - async add(_data) { + async add(source) { + for await (const _chunk of source) { + } return { cid: "bafyResult456" }; }, }; @@ -103,6 +216,541 @@ test(`${variant}: ipfsAddStream result should be collected by pipeline`, async ( strictEqual(result.cid, "bafyResult456"); }); +test(`${variant}: ipfsAddStream should terminate node.add source when the stream is destroyed`, async () => { + // node.add iterates the source forever until it ends/throws. When the + // transform is destroyed (e.g. aborted), the source generator must throw or + // return so node.add settles instead of hanging on a parked promise. + let iterationSettled = false; + let iterationError; + const node = { + async add(source) { + try { + for await (const _chunk of source) { + // keep consuming + } + } catch (e) { + iterationError = e; + } finally { + iterationSettled = true; + } + return { cid: "QmDestroyed" }; + }, + }; + const stream = await ipfsAddStream({ node }); + // Write one chunk so node.add is actively iterating, then destroy. + stream.write("a"); + await new Promise((resolve) => setTimeout(resolve, 10)); + stream.destroy(new Error("aborted")); + + // Wait up to 1s for node.add's source iteration to settle. + const settled = await Promise.race([ + (async () => { + while (!iterationSettled) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return true; + })(), + new Promise((resolve) => setTimeout(() => resolve(false), 1000)), + ]); + strictEqual(settled, true); + // The generator should have thrown the destroy error into node.add. + strictEqual(iterationError instanceof Error, true); +}); + +test(`${variant}: ipfsAddStream should terminate node.add source when the pipeline errors upstream`, async () => { + let iterationSettled = false; + const node = { + async add(source) { + try { + for await (const _chunk of source) { + // keep consuming + } + } finally { + iterationSettled = true; + } + return { cid: "QmUpstreamError" }; + }, + }; + // Upstream readable that emits one chunk then errors. + const upstream = createReadableStream(undefined); + upstream.push("a"); + const addStream = await ipfsAddStream({ node }); + const run = pipeline([upstream, addStream]); + await new Promise((resolve) => setTimeout(resolve, 10)); + upstream.destroy(new Error("upstream boom")); + await rejects(() => run, /boom/); + + // node.add's source iteration must settle, not hang forever. + const settled = await Promise.race([ + (async () => { + while (!iterationSettled) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return true; + })(), + new Promise((resolve) => setTimeout(() => resolve(false), 1000)), + ]); + strictEqual(settled, true); +}); + +test(`${variant}: ipfsAddStream should bound queue depth under a slow consumer (backpressure)`, async () => { + // node.add consumes slowly. The transform must apply real backpressure so + // the producer can't race ahead and buffer the whole input in memory. + const total = 50; + let accepted = 0; // chunks the transform has taken from upstream + let consumed = 0; // chunks node.add has pulled from the source + let maxInFlight = 0; // max (accepted - consumed): unconsumed queue depth + + const node = { + async add(source) { + for await (const _chunk of source) { + consumed += 1; + // Slow consumer. + await new Promise((resolve) => setTimeout(resolve, 5)); + } + return { cid: "QmBounded" }; + }, + }; + + const input = createReadableStream(undefined); + // Observe how many chunks the transform accepts before they are consumed. + const observer = await ipfsAddStream({ node }, { highWaterMark: 2 }); + const originalWrite = observer.write.bind(observer); + observer.write = (chunk, ...rest) => { + accepted += 1; + maxInFlight = Math.max(maxInFlight, accepted - consumed); + return originalWrite(chunk, ...rest); + }; + + const run = pipeline([input, observer]); + for (let i = 0; i < total; i++) { + input.push(`chunk-${i}`); + } + input.push(null); + const result = await run; + strictEqual(result.cid, "QmBounded"); + strictEqual(consumed, total); + // With real backpressure the unconsumed in-flight depth stays small and + // bounded; without it, the producer races ahead and buffers ~all `total`. + strictEqual( + maxInFlight < total, + true, + `queue depth ${maxInFlight} should be bounded well below ${total}`, + ); + strictEqual( + maxInFlight <= 10, + true, + `queue depth ${maxInFlight} should stay small under backpressure`, + ); +}); + +test(`${variant}: ipfsAddStream should throw a clear error when node.add omits cid`, async () => { + // A legacy / differently-shaped client may resolve without a { cid }. The + // pipeline should reject with an actionable message, not an opaque + // "Cannot read properties of undefined (reading 'cid')". + const node = { + async add(source) { + for await (const _chunk of source) { + } + return undefined; + }, + }; + const streams = [createReadableStream(["a"]), await ipfsAddStream({ node })]; + await rejects(() => pipeline(streams), /node\.add did not return/); +}); + +test(`${variant}: ipfsAddStream should terminate node.add source on abort signal`, async () => { + let iterationSettled = false; + const node = { + async add(source) { + try { + for await (const _chunk of source) { + } + } finally { + iterationSettled = true; + } + return { cid: "QmAborted" }; + }, + }; + const controller = new AbortController(); + const input = createReadableStream(undefined, { signal: controller.signal }); + const addStream = await ipfsAddStream( + { node }, + { signal: controller.signal }, + ); + const run = pipeline([input, addStream], { signal: controller.signal }); + input.push("a"); + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + await rejects(() => run); + + const settled = await Promise.race([ + (async () => { + while (!iterationSettled) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return true; + })(), + new Promise((resolve) => setTimeout(() => resolve(false), 1000)), + ]); + strictEqual(settled, true); +}); + +// *** ipfsAddStream result validation guards *** // + +// Kill: ConditionalExpression survivors on line 49 (`result == null` → false, and +// `typeof result !== "object"` → false). A non-null primitive result must still throw +// because the `typeof` guard must remain active. +test(`${variant}: ipfsAddStream should throw a clear error when node.add returns a non-object (string)`, async () => { + const node = { + async add(source) { + for await (const _chunk of source) { + } + // A string is non-null AND non-object; strips the null-check branch but + // the typeof branch should fire. + return "QmNotAnObject"; + }, + }; + const streams = [createReadableStream(["a"]), await ipfsAddStream({ node })]; + await rejects(() => pipeline(streams), /node\.add did not return/); +}); + +// Kill: ConditionalExpression survivor where `typeof result !== "object"` → false. +// An object that lacks a "cid" property must still throw, exercising the +// `!("cid" in result)` branch independently. +test(`${variant}: ipfsAddStream should throw a clear error when node.add returns an object without cid`, async () => { + const node = { + async add(source) { + for await (const _chunk of source) { + } + // Non-null object, passes typeof check, but has no "cid" key. + return { hash: "QmSomethingElse" }; + }, + }; + const streams = [createReadableStream(["a"]), await ipfsAddStream({ node })]; + await rejects(() => pipeline(streams), /node\.add did not return/); +}); + +// Kill: LogicalOperator survivor where first `||` → `&&`. +// A boolean (non-null, non-object) must trigger the error via the typeof branch. +test(`${variant}: ipfsAddStream should throw a clear error when node.add returns a boolean`, async () => { + const node = { + async add(source) { + for await (const _chunk of source) { + } + return true; + }, + }; + const streams = [createReadableStream(["a"]), await ipfsAddStream({ node })]; + await rejects(() => pipeline(streams), /node\.add did not return/); +}); + +// Kill: ConditionalExpression survivor `result == null` → `false` (line 47). +// null is the one value where `result == null` is true but `typeof null` is +// "object", so with `result == null` replaced by `false`, the code would fall +// through to `!("cid" in null)` which throws a TypeError (not our message). +test(`${variant}: ipfsAddStream should throw a clear error when node.add returns null`, async () => { + const node = { + async add(source) { + for await (const _chunk of source) { + } + return null; + }, + }; + const streams = [createReadableStream(["a"]), await ipfsAddStream({ node })]; + await rejects(() => pipeline(streams), /node\.add did not return/); +}); + +// *** teardown / error path tests *** // + +// Kill: AssignmentOperator survivor `error ??= e` → `error &&= e` (line 55). +// When error is already recorded and add() rejects, the original error must be preserved. +test(`${variant}: ipfsAddStream should preserve the first error when add rejects after a prior error`, async () => { + const node = { + async add(_source) { + // Reject immediately without consuming any chunks. + throw new Error("add error"); + }, + }; + const addStream = await ipfsAddStream({ node }); + const streams = [createReadableStream(["a"]), addStream]; + await rejects(() => pipeline(streams), /add error/); + strictEqual(addStream.result().value, undefined); +}); + +// Kill: BooleanLiteral survivor `consumerDone = true` → `consumerDone = false` (line 56). +// When add() resolves successfully before all chunks have been pushed (early resolve), +// consumerDone must be set to true so subsequent parked writes resolve immediately +// instead of hanging forever waiting for a consumer that no longer exists. +test(`${variant}: ipfsAddStream should not block writes after node.add resolves early (consumerDone)`, async () => { + let addSettled = false; + const node = { + async add(source) { + // Consume just one chunk then return — simulating a client that resolves + // without draining the full source. + for await (const _chunk of source) { + break; + } + addSettled = true; + return { cid: "QmEarlyResolve" }; + }, + }; + const addStream = await ipfsAddStream({ node }); + + // Write a chunk to kick off add()'s consumption of the source. + addStream.write("first"); + // Wait for add() to settle (it consumed one chunk and returned { cid }). + const settled = await Promise.race([ + (async () => { + while (!addSettled) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return true; + })(), + new Promise((resolve) => setTimeout(() => resolve(false), 1000)), + ]); + strictEqual(settled, true, "add() should settle quickly after first chunk"); + + // Now write another chunk; since consumerDone=true (add settled successfully), + // the transform must resolve immediately without parking. + const timedOut = await Promise.race([ + new Promise((resolve) => { + addStream.write("second", () => resolve(false)); + }), + new Promise((resolve) => setTimeout(() => resolve(true), 500)), + ]); + strictEqual( + timedOut, + false, + "write after add() early resolve should not hang (consumerDone must be true)", + ); + addStream.destroy(); +}); + +// Kill: ConditionalExpression survivor `if (error)` → `if (false)` in transform callback +// (line 68), and the BlockStatement survivor where the reject+return block is removed. +// When error is already set before a chunk is written, transform() must reject +// immediately (not park the chunk or silently resolve). +test(`${variant}: ipfsAddStream should reject immediately when error is set before writing`, async () => { + const node = { + async add(_source) { + // Reject immediately without consuming any chunks. + throw new Error("add error before write"); + }, + }; + const addStream = await ipfsAddStream({ node }); + // Let the add() rejection propagate and set `error`. + await new Promise((resolve) => setTimeout(resolve, 30)); + + // Now write a chunk; since `error` is set, transform() must reject, not hang. + const writeResult = await Promise.race([ + new Promise((resolve, reject) => { + addStream.write("chunk", (err) => (err ? reject(err) : resolve("ok"))); + }).catch((e) => ({ error: e })), + new Promise((resolve) => setTimeout(() => resolve("timeout"), 500)), + ]); + strictEqual( + writeResult !== "timeout", + true, + "write must not hang when error is already set", + ); + // The write should have produced an error callback (not silently resolved). + strictEqual( + typeof writeResult === "object" && writeResult.error instanceof Error, + true, + "write should reject with an error when error is pre-set", + ); + addStream.destroy(); +}); + +// Kill: ConditionalExpression survivors on line 79 `if (error) reject(error)` → +// `if (true)` or `if (false)`. The resolvePulled callback runs when source() pulls +// a chunk. If an error was injected between parking and pulling, the producer +// (transform callback) must reject, not resolve. +test(`${variant}: ipfsAddStream should reject the pending write when error is set while a chunk is parked`, async () => { + let resolveReady; + const ready = new Promise((r) => { + resolveReady = r; + }); + const node = { + async add(source) { + // Signal that we are ready to start consuming, then pause before pulling. + resolveReady(); + await new Promise((resolve) => setTimeout(resolve, 50)); // park + // Now drain so source() gets woken and the error branch is exercised. + for await (const _chunk of source) { + } + return { cid: "QmRace" }; + }, + }; + const addStream = await ipfsAddStream({ node }); + await ready; // add() has started + + // Park a chunk (filled=true, resolvePulled set), then inject an error before + // source() wakes up to pull it. + const writePromise = new Promise((resolve, reject) => { + addStream.write("parked-chunk", (err) => + err ? reject(err) : resolve("ok"), + ); + }); + // Short wait so the chunk is parked (filled=true, resolvePulled is set). + await new Promise((resolve) => setTimeout(resolve, 10)); + // Inject error via destroy, which calls teardown → sets error → calls onPulled. + addStream.destroy(new Error("injected error")); + + const result = await Promise.race([ + writePromise.catch((e) => ({ error: e })), + new Promise((resolve) => setTimeout(() => resolve("timeout"), 500)), + ]); + strictEqual(result !== "timeout", true, "write should not hang"); + strictEqual( + typeof result === "object" && result.error instanceof Error, + true, + "write should reject when error is set while chunk is parked", + ); +}); + +// Kill: ConditionalExpression survivor `if (done) return` → `if (false) return` (line 93). +// teardown() after done=true must be a no-op: error must stay undefined when called +// with no argument after a successful flush. +test(`${variant}: ipfsAddStream teardown after done should not set error`, async () => { + const node = { + async add(source) { + for await (const _chunk of source) { + } + return { cid: "QmDoneFirst" }; + }, + }; + const addStream = await ipfsAddStream({ node }); + const streams = [createReadableStream(["a"]), addStream]; + // Complete the pipeline successfully; this sets done=true then awaits addPromise. + const res = await pipeline(streams); + strictEqual(res.cid, "QmDoneFirst"); + // A clean completion must leave no injected teardown error: the "close" event + // fired naturally during teardown, and with `if (done) return` intact that + // teardown is a no-op. If the guard is dropped (`if (false) return`), teardown + // fabricates an "ipfsAddStream destroyed" error here. + strictEqual( + addStream.injectedError(), + undefined, + "a clean completion must not inject a teardown error", + ); + // Emitting "close" again must likewise stay a no-op. + addStream.emit("close"); + strictEqual( + addStream.injectedError(), + undefined, + "teardown after done must remain a no-op on a repeated close", + ); + // result must still be valid + strictEqual(addStream.result().value, "QmDoneFirst"); +}); + +// Kill: LogicalOperator survivor `error ??= e ?? new Error(...)` → `e && new Error(...)` +// (line 94). When teardown is called without an argument (e is undefined), the +// fallback `new Error("ipfsAddStream destroyed")` must be used, not `undefined`. +test(`${variant}: ipfsAddStream teardown without argument should set a fallback error`, async () => { + let sourceError; + const node = { + async add(source) { + try { + for await (const _chunk of source) { + } + } catch (e) { + sourceError = e; + } + return { cid: "QmFallback" }; + }, + }; + const addStream = await ipfsAddStream({ node }); + addStream.write("a"); + await new Promise((resolve) => setTimeout(resolve, 10)); + // Destroy with no error argument → teardown(undefined) → fallback Error needed. + addStream.destroy(); + await new Promise((resolve) => setTimeout(resolve, 50)); + // source() must have thrown a real Error, not undefined. + strictEqual( + sourceError instanceof Error, + true, + "source should throw a real Error even when teardown called without argument", + ); +}); + +// Kill: StringLiteral survivor `new Error("ipfsAddStream destroyed")` → `new Error("")` +// (line 94). The fallback error must carry the descriptive message. +test(`${variant}: ipfsAddStream teardown fallback error message should describe the cause`, async () => { + let sourceError; + const node = { + async add(source) { + try { + for await (const _chunk of source) { + } + } catch (e) { + sourceError = e; + } + return { cid: "QmMsg" }; + }, + }; + const addStream = await ipfsAddStream({ node }); + addStream.write("a"); + await new Promise((resolve) => setTimeout(resolve, 10)); + addStream.destroy(); // no error arg → fallback message used + await new Promise((resolve) => setTimeout(resolve, 50)); + strictEqual(sourceError instanceof Error, true); + strictEqual( + sourceError.message.includes("ipfsAddStream destroyed"), + true, + `expected "ipfsAddStream destroyed" in message, got: "${sourceError?.message}"`, + ); +}); + +// Kill: StringLiteral survivor `stream.on("close", ...)` → `stream.on("", ...)` +// (line 99), and the ArrowFunction survivor `() => teardown()` → `() => undefined`. +// Destroying a stream without an error fires "close" but not "error"; if the "close" +// handler is missing or is a no-op, teardown never runs and source() hangs forever. +test(`${variant}: ipfsAddStream should settle node.add source when stream is closed without error`, async () => { + let iterationSettled = false; + let iterationError; + const node = { + async add(source) { + try { + for await (const _chunk of source) { + } + } catch (e) { + iterationError = e; + } finally { + iterationSettled = true; + } + return { cid: "QmClose" }; + }, + }; + const addStream = await ipfsAddStream({ node }); + addStream.write("a"); + await new Promise((resolve) => setTimeout(resolve, 10)); + // Destroy without passing an error so only "close" fires (no "error" emitted). + addStream.destroy(); + + const settled = await Promise.race([ + (async () => { + while (!iterationSettled) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return true; + })(), + new Promise((resolve) => setTimeout(() => resolve(false), 1000)), + ]); + strictEqual( + settled, + true, + "node.add source must settle when stream is closed without error (close event)", + ); + strictEqual(iterationError instanceof Error, true); + // teardown ran (stream was destroyed before completion), so the injected + // error must be exposed — it is the very error source() threw into node.add. + strictEqual(addStream.injectedError() instanceof Error, true); + strictEqual(addStream.injectedError(), iterationError); +}); + // *** default export *** // test(`${variant}: default export should include all stream functions`, () => { deepStrictEqual(Object.keys(ipfsDefault).sort(), ["addStream", "getStream"]); diff --git a/packages/ipfs/package.json b/packages/ipfs/package.json index 653c3d5..cd2fb6e 100644 --- a/packages/ipfs/package.json +++ b/packages/ipfs/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/ipfs", - "version": "0.5.0", + "version": "0.6.1", "description": "IPFS get and add streaming operations", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -61,6 +62,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/json/README.md b/packages/json/README.md index 01f83cc..a02a623 100644 --- a/packages/json/README.md +++ b/packages/json/README.md @@ -1,6 +1,6 @@

<datastream> `json`

- datastream logo + datastream logo

JSON and NDJSON (JSON Lines) parsing and formatting transform streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/json/index.d.ts b/packages/json/index.d.ts index e223ced..2e2b9b8 100644 --- a/packages/json/index.d.ts +++ b/packages/json/index.d.ts @@ -25,7 +25,6 @@ export function ndjsonParseStream( export function ndjsonFormatStream( options?: { space?: number | string; - resultKey?: string; }, streamOptions?: StreamOptions, ): DatastreamTransform, string>; diff --git a/packages/json/index.js b/packages/json/index.js index d825048..e875cad 100644 --- a/packages/json/index.js +++ b/packages/json/index.js @@ -29,9 +29,12 @@ export const ndjsonParseStream = (options = {}, streamOptions = {}) => { buffer = buffer.substring(pos); break; } - const line = buffer.substring(pos, nlIdx).trimEnd(); + const line = buffer.substring(pos, nlIdx); pos = nlIdx + 1; - if (line.length === 0) continue; + // Skip blank/whitespace-only lines. JSON.parse already tolerates + // surrounding whitespace (incl. a trailing \r from \r\n), so the raw + // line is parsed directly. + if (!/\S/.test(line)) continue; try { enqueue(JSON.parse(line)); } catch { @@ -42,16 +45,18 @@ export const ndjsonParseStream = (options = {}, streamOptions = {}) => { }; const flush = (enqueue) => { - const line = buffer.trimEnd(); - if (line.length > 0) { + // Parse the trailing (unterminated) line if it has any content. JSON.parse + // tolerates surrounding whitespace, so the raw buffer is parsed directly. + if (/\S/.test(buffer)) { try { - enqueue(JSON.parse(line)); + enqueue(JSON.parse(buffer)); } catch { trackError("ParseError", "Invalid JSON"); } - idx++; + // No idx++ here: flush is terminal, so a post-increment would be dead. } - buffer = ""; + // No buffer reset here: flush is terminal and the buffer is never read + // again, so a reset would be dead code. }; const stream = createTransformStream(transform, flush, streamOptions); @@ -112,10 +117,10 @@ export const jsonParseStream = (options = {}, streamOptions = {}) => { `jsonParseStream value size (${text.length}) exceeds maxValueSize (${maxValueSize})`, ); } - const trimmed = text.trim(); - if (trimmed.length === 0) return; + // JSON.parse tolerates surrounding whitespace, so the raw element text is + // parsed directly; callers only reach here with non-whitespace content. try { - enqueue(JSON.parse(trimmed)); + enqueue(JSON.parse(text)); } catch { trackError("ParseError", "Invalid JSON"); } @@ -171,7 +176,9 @@ export const jsonParseStream = (options = {}, streamOptions = {}) => { if (ch === 0x5d || ch === 0x7d) { if (depth > 0) { depth--; - if (depth === 0 && elementStart !== -1) { + // depth was > 0, so the matching open brace already set + // elementStart; it is provably !== -1 here. + if (depth === 0) { emitElement(buffer.substring(elementStart, scanPos + 1), enqueue); elementStart = -1; } @@ -207,14 +214,16 @@ export const jsonParseStream = (options = {}, streamOptions = {}) => { scanPos++; } - // Trim processed portion from buffer + // Trim the processed portion from the buffer. trimFrom is always >= 0: + // it is either elementStart (>= 0 when an element is in progress) or + // scanPos (>= 0). A trimFrom of 0 makes the substring/offset updates + // no-ops, so the trim runs unconditionally. const trimFrom = elementStart !== -1 ? elementStart : scanPos; - if (trimFrom > 0) { - buffer = buffer.substring(trimFrom); - scanPos -= trimFrom; - if (elementStart !== -1) { - elementStart = 0; - } + buffer = buffer.substring(trimFrom); + scanPos -= trimFrom; + if (elementStart !== -1) { + // The in-progress element now begins at the start of the buffer. + elementStart = 0; } }; @@ -230,16 +239,19 @@ export const jsonParseStream = (options = {}, streamOptions = {}) => { }; const flush = (enqueue) => { - if (elementStart !== -1 && buffer.length > 0) { - const trimmed = buffer.substring(elementStart).trim(); - if (trimmed.length > 0 && trimmed !== "]") { - emitElement(trimmed, enqueue); - } + // After scan(), the buffer holds exactly the trailing in-progress element + // (starting at its first non-whitespace char) or only whitespace when no + // element is pending. Emit the raw buffer as an element when it contains + // any non-whitespace; emitElement/JSON.parse tolerate surrounding + // whitespace. The scanner never leaves a structural `]` pending, so no + // special closing-bracket guard is needed. + if (/\S/.test(buffer)) { + emitElement(buffer, enqueue); } if (!started && sawNonWhitespace) { trackError("NoArrayStart", "Input did not contain a top-level array"); } - buffer = ""; + // flush is terminal; the buffer is never read again, so no reset is needed. }; const stream = createTransformStream(transform, flush, streamOptions); diff --git a/packages/json/index.test.js b/packages/json/index.test.js index 607820c..ff6c143 100644 --- a/packages/json/index.test.js +++ b/packages/json/index.test.js @@ -344,3 +344,700 @@ test(`${variant}: jsonFormatStream roundtrip with jsonParseStream`, async (_t) = deepStrictEqual(output, input); }); + +// *** Additional tests to improve mutation score *** // + +// --- ndjsonParseStream: error deduplication (trackError guard) --- +test(`${variant}: ndjsonParseStream should accumulate idx list for repeated ParseError`, async (_t) => { + const parse = ndjsonParseStream(); + const streams = [createReadableStream("bad1\nbad2\nbad3\n"), parse]; + await streamToArray(pipejoin(streams)); + const { value } = parse.result(); + // All three errors under same ParseError key - idx has 3 entries + deepStrictEqual(value.ParseError.id, "ParseError"); + deepStrictEqual(value.ParseError.message, "Invalid JSON"); + deepStrictEqual(value.ParseError.idx, [0, 1, 2]); +}); + +// --- ndjsonParseStream: maxBufferSize boundary (> not >=) --- +test(`${variant}: ndjsonParseStream should not throw at exactly maxBufferSize`, async (_t) => { + // buffer.length + chunk.length > maxBufferSize: equal should NOT throw + const maxBufferSize = 10; + let error; + try { + await pipeline([ + createReadableStream("a".repeat(10)), + ndjsonParseStream({ maxBufferSize }), + ]); + } catch (e) { + error = e; + } + deepStrictEqual(error, undefined); +}); + +test(`${variant}: ndjsonParseStream should throw one byte over maxBufferSize`, async (_t) => { + // Two chunks: first 10 chars (no newline → stays in buffer), then 1 more → 11 > 10 + let error; + try { + await pipeline([ + createReadableStream(["a".repeat(10), "b"]), + ndjsonParseStream({ maxBufferSize: 10 }), + ]); + } catch (e) { + error = e; + } + deepStrictEqual(error?.message?.includes("maxBufferSize"), true); +}); + +// --- ndjsonParseStream: error message uses addition (not subtraction) --- +test(`${variant}: ndjsonParseStream error message contains combined length and limit`, async (_t) => { + let error; + try { + await pipeline([ + createReadableStream(["a".repeat(10), "b".repeat(5)]), + ndjsonParseStream({ maxBufferSize: 12 }), + ]); + } catch (e) { + error = e; + } + // buffer.length (10) + chunk.length (5) = 15; limit = 12 + deepStrictEqual(error?.message?.includes("15"), true); + deepStrictEqual(error?.message?.includes("12"), true); +}); + +// --- ndjsonParseStream: trimEnd (not trimStart) for lines --- +test(`${variant}: ndjsonParseStream flush trims trailing whitespace (\\r) on last line`, async (_t) => { + // No trailing newline; line ends with \r. trimEnd removes it, trimStart would not. + const streams = [createReadableStream('{"a":1}\r'), ndjsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }]); +}); + +// --- ndjsonParseStream: flush catch block (not empty) --- +test(`${variant}: ndjsonParseStream flush should track error for invalid JSON without trailing newline`, async (_t) => { + const parse = ndjsonParseStream(); + const streams = [createReadableStream("not-json"), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, []); + const { value } = parse.result(); + deepStrictEqual(value.ParseError?.id, "ParseError"); + deepStrictEqual(value.ParseError?.message, "Invalid JSON"); + deepStrictEqual(value.ParseError?.idx, [0]); +}); + +// --- ndjsonParseStream: flush idx++ (not --) --- +test(`${variant}: ndjsonParseStream flush should use correct idx for flushed error`, async (_t) => { + // One valid transform line, one invalid flush line → error at idx 1 + const parse = ndjsonParseStream(); + const streams = [createReadableStream('{"a":1}\nbad'), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }]); + const { value } = parse.result(); + deepStrictEqual(value.ParseError.idx, [1]); +}); + +// --- ndjsonParseStream: resultKey --- +test(`${variant}: ndjsonParseStream should use custom resultKey`, async (_t) => { + const parse = ndjsonParseStream({ resultKey: "myErrors" }); + const streams = [createReadableStream('{"a":1}\n'), parse]; + await streamToArray(pipejoin(streams)); + const { key } = parse.result(); + deepStrictEqual(key, "myErrors"); +}); + +// --- ndjsonFormatStream: batching >= 64 --- +test(`${variant}: ndjsonFormatStream should flush eagerly after exactly 64 items`, async (_t) => { + const items = Array.from({ length: 64 }, (_, i) => ({ i })); + const streams = [createReadableStream(items), ndjsonFormatStream()]; + const combined = (await streamToArray(pipejoin(streams))).join(""); + const lines = combined.trim().split("\n"); + deepStrictEqual(lines.length, 64); + deepStrictEqual(JSON.parse(lines[0]), { i: 0 }); + deepStrictEqual(JSON.parse(lines[63]), { i: 63 }); +}); + +test(`${variant}: ndjsonFormatStream should handle 65 items (one full batch plus one)`, async (_t) => { + const items = Array.from({ length: 65 }, (_, i) => ({ i })); + const streams = [createReadableStream(items), ndjsonFormatStream()]; + const combined = (await streamToArray(pipejoin(streams))).join(""); + const lines = combined.trim().split("\n"); + deepStrictEqual(lines.length, 65); + deepStrictEqual(JSON.parse(lines[64]), { i: 64 }); +}); + +// --- ndjsonFormatStream: newline separator (not empty string) --- +test(`${variant}: ndjsonFormatStream items joined with newline separator`, async (_t) => { + const streams = [ + createReadableStream([{ a: 1 }, { b: 2 }]), + ndjsonFormatStream(), + ]; + const combined = (await streamToArray(pipejoin(streams))).join(""); + deepStrictEqual(combined.endsWith("\n"), true); + const lines = combined.split("\n").filter((l) => l.length > 0); + deepStrictEqual(lines.length, 2); + deepStrictEqual(lines[0], '{"a":1}'); + deepStrictEqual(lines[1], '{"b":2}'); +}); + +// --- ndjsonFormatStream: flush only when batch.length > 0 --- +test(`${variant}: ndjsonFormatStream should not emit anything when no items`, async (_t) => { + const streams = [createReadableStream([]), ndjsonFormatStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, []); +}); + +test(`${variant}: ndjsonFormatStream should flush single item`, async (_t) => { + const streams = [createReadableStream([{ x: 42 }]), ndjsonFormatStream()]; + const combined = (await streamToArray(pipejoin(streams))).join(""); + deepStrictEqual(combined, '{"x":42}\n'); +}); + +// --- ndjsonFormatStream: space option --- +test(`${variant}: ndjsonFormatStream should use space option for pretty-printing`, async (_t) => { + const streams = [ + createReadableStream([{ a: 1 }]), + ndjsonFormatStream({ space: 2 }), + ]; + const combined = (await streamToArray(pipejoin(streams))).join(""); + deepStrictEqual(combined, '{\n "a": 1\n}\n'); +}); + +// --- jsonParseStream: trackError deduplication --- +test(`${variant}: jsonParseStream should accumulate idx list for repeated ParseError`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream("[bad1,bad2,bad3]"), parse]; + await streamToArray(pipejoin(streams)); + const { value } = parse.result(); + deepStrictEqual(value.ParseError.id, "ParseError"); + deepStrictEqual(value.ParseError.message, "Invalid JSON"); + deepStrictEqual(value.ParseError.idx, [0, 1, 2]); +}); + +// --- jsonParseStream: maxValueSize boundary (> not >=) --- +test(`${variant}: jsonParseStream should not throw at exactly maxValueSize`, async (_t) => { + // element "12345" is 5 chars, not > 5, should parse fine + const streams = [ + createReadableStream("[12345]"), + jsonParseStream({ maxValueSize: 5 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [12345]); +}); + +test(`${variant}: jsonParseStream should throw when element exceeds maxValueSize`, async (_t) => { + // "[ 123456 ]" → raw element " 123456 " = 8 chars > 5 + let error; + try { + await pipeline([ + createReadableStream("[ 123456 ]"), + jsonParseStream({ maxValueSize: 5 }), + ]); + } catch (e) { + error = e; + } + deepStrictEqual(error?.message?.includes("maxValueSize"), true); +}); + +// --- jsonParseStream: emitElement trims text before parse --- +test(`${variant}: jsonParseStream should skip whitespace-only elements without error`, async (_t) => { + // Exercises trimmed.length === 0 early return in emitElement + const streams = [createReadableStream("[ ]"), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, []); +}); + +// --- jsonParseStream: escaped char handling --- +test(`${variant}: jsonParseStream should handle escaped quote in string`, async (_t) => { + const streams = [createReadableStream('[{"a":"x\\"y"}]'), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 'x"y' }]); +}); + +test(`${variant}: jsonParseStream should handle double backslash in string`, async (_t) => { + const streams = [createReadableStream('[{"a":"\\\\"}]'), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "\\" }]); +}); + +// --- jsonParseStream: inString prevents depth changes for brackets in strings --- +test(`${variant}: jsonParseStream should not count brackets inside strings as depth`, async (_t) => { + const streams = [ + createReadableStream('[{"a":"[{nested}]"}]'), + jsonParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "[{nested}]" }]); +}); + +// --- jsonParseStream: string as top-level element (opens inString) --- +test(`${variant}: jsonParseStream should parse string elements at top level`, async (_t) => { + const streams = [ + createReadableStream('["hello","world"]'), + jsonParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, ["hello", "world"]); +}); + +// --- jsonParseStream: whitespace chars (tab 0x09, LF 0x0a, CR 0x0d) --- +test(`${variant}: jsonParseStream should treat tab as whitespace before elements`, async (_t) => { + const streams = [createReadableStream("[1,\t2,\t3]"), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1, 2, 3]); +}); + +test(`${variant}: jsonParseStream should treat CR as whitespace before elements`, async (_t) => { + const streams = [createReadableStream("[1,\r2,\r3]"), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1, 2, 3]); +}); + +// --- jsonParseStream: buffer trimming (trimFrom) --- +test(`${variant}: jsonParseStream should work correctly with many small chunks`, async (_t) => { + const chunks = ["[", '{"a":', "1}", ",", '{"b":', "2}", "]"]; + const streams = [createReadableStream(chunks), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }, { b: 2 }]); +}); + +test(`${variant}: jsonParseStream should handle multiple elements across chunks`, async (_t) => { + const chunks = ['[{"a":1},', '{"b":2},', '{"c":3}]']; + const streams = [createReadableStream(chunks), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }, { b: 2 }, { c: 3 }]); +}); + +// --- jsonParseStream: flush ']' guard --- +test(`${variant}: jsonParseStream flush should not emit lone closing bracket as element`, async (_t) => { + const streams = [createReadableStream("[1,2]"), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1, 2]); +}); + +// --- jsonParseStream: sawNonWhitespace for NoArrayStart --- +test(`${variant}: jsonParseStream whitespace-only input should not flag NoArrayStart`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream(" \n\t "), parse]; + await streamToArray(pipejoin(streams)); + const { value } = parse.result(); + deepStrictEqual(value.NoArrayStart, undefined); +}); + +test(`${variant}: jsonParseStream non-whitespace without [ should flag NoArrayStart with message`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream("abc"), parse]; + await streamToArray(pipejoin(streams)); + const { value } = parse.result(); + deepStrictEqual(value.NoArrayStart?.id, "NoArrayStart"); + deepStrictEqual( + value.NoArrayStart?.message, + "Input did not contain a top-level array", + ); +}); + +// --- jsonParseStream: maxBufferSize error message uses addition --- +test(`${variant}: jsonParseStream maxBufferSize error message contains combined length`, async (_t) => { + let error; + try { + await pipeline([ + createReadableStream(`[{"a":"${"x".repeat(10)}"}]`), + jsonParseStream({ maxBufferSize: 15 }), + ]); + } catch (e) { + error = e; + } + deepStrictEqual(error?.message?.includes("maxBufferSize"), true); + // The message includes the buffer + chunk length (not subtraction) + // Since "[{\"a\":\"" = 7 chars stays in buffer, then adding more chars triggers limit + // Just verify the format matches expected pattern + deepStrictEqual(error?.message?.includes("exceeds"), true); +}); + +// --- jsonParseStream: flush handles no elementStart gracefully --- +test(`${variant}: jsonParseStream flush with no elementStart does not crash`, async (_t) => { + const streams = [createReadableStream('[{"a":1}]'), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }]); +}); + +// --- jsonParseStream: depth tracking --- +test(`${variant}: jsonParseStream should parse deeply nested objects`, async (_t) => { + const streams = [ + createReadableStream('[{"a":{"b":{"c":1}}}]'), + jsonParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: { b: { c: 1 } } }]); +}); + +// --- jsonParseStream: error idx ordering --- +test(`${variant}: jsonParseStream error idx should reflect insertion order`, async (_t) => { + const parse = jsonParseStream(); + const streams = [ + createReadableStream('[{"a":1},bad,{"b":2},worse,{"c":3}]'), + parse, + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }, { b: 2 }, { c: 3 }]); + const { value } = parse.result(); + deepStrictEqual(value.ParseError.idx, [1, 3]); +}); + +// --- jsonParseStream: resultKey --- +test(`${variant}: jsonParseStream should use custom resultKey`, async (_t) => { + const parse = jsonParseStream({ resultKey: "myErrors" }); + const streams = [createReadableStream('[{"a":1}]'), parse]; + await streamToArray(pipejoin(streams)); + const { key } = parse.result(); + deepStrictEqual(key, "myErrors"); +}); + +// --- jsonFormatStream: first/non-first item separator --- +test(`${variant}: jsonFormatStream first element uses [ prefix; rest use comma-newline`, async (_t) => { + const streams = [ + createReadableStream([{ a: 1 }, { b: 2 }, { c: 3 }]), + jsonFormatStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output[0], '[{"a":1}'); + deepStrictEqual(output[1], ',\n{"b":2}'); + deepStrictEqual(output[2], ',\n{"c":3}'); +}); + +// --- jsonFormatStream: flush emits \n] for non-empty --- +test(`${variant}: jsonFormatStream flush emits newline-bracket for non-empty stream`, async (_t) => { + const streams = [createReadableStream([{ a: 1 }]), jsonFormatStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output[output.length - 1], "\n]"); +}); + +test(`${variant}: jsonFormatStream single number produces correct JSON array`, async (_t) => { + const streams = [createReadableStream([42]), jsonFormatStream()]; + const combined = (await streamToArray(pipejoin(streams))).join(""); + deepStrictEqual(combined, "[42\n]"); +}); + +// --- jsonParseStream: mixed object and string elements --- +test(`${variant}: jsonParseStream should parse mixed object and string elements`, async (_t) => { + const streams = [ + createReadableStream('[{"a":1},"hello"]'), + jsonParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }, "hello"]); +}); + +// --- jsonParseStream: flush emitElement with ']' trimmed --- +test(`${variant}: jsonParseStream flush should emit incomplete element at end of stream`, async (_t) => { + // Input split across chunks where last chunk has no closing bracket scanned + // to exercise flush emitElement path + const streams = [ + createReadableStream(['[{"a":1},', '{"b":2']), + jsonParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + // {"b":2 is incomplete JSON but flush emits it, parse fails → tracked as error + deepStrictEqual(output, [{ a: 1 }]); + // The incomplete element causes a ParseError +}); + +// --- ndjsonParseStream: transform empty line skip guard --- +test(`${variant}: ndjsonParseStream should count idx correctly when skipping empty lines`, async (_t) => { + // Two valid lines with empty lines in between + // Empty lines don't increment idx, so second parse error should be at idx 1 + const parse = ndjsonParseStream(); + const streams = [createReadableStream('{"a":1}\n\nbad\n{"c":3}\n'), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }, { c: 3 }]); + const { value } = parse.result(); + deepStrictEqual(value.ParseError.idx, [1]); +}); + +// --- jsonParseStream: flush element with actual content --- +test(`${variant}: jsonParseStream flush emits element for incomplete array without closing bracket`, async (_t) => { + // Array missing closing ] - flush should emit what it has + const parse = jsonParseStream(); + const streams = [createReadableStream('[{"a":1},{"b":2}'), parse]; + const output = await streamToArray(pipejoin(streams)); + // {"a":1} is emitted during scan; {"b":2} has no closing } so it stays in buffer + // flush tries to emit the remaining element + deepStrictEqual(output.length >= 1, true); + deepStrictEqual(output[0], { a: 1 }); +}); + +// *** Second-round targeted tests for remaining survivors *** // + +// --- ndjsonFormatStream: chunk count distinguishes >= 64 from > 64 --- +test(`${variant}: ndjsonFormatStream 65 items should produce 2 output chunks`, async (_t) => { + // Real (>= 64): at item 64, eager flush -> 64-item chunk; flush() -> 1-item chunk -> 2 chunks + // Mutant (> 64): at item 65, eager flush -> 65-item chunk; flush() nothing -> 1 chunk + const items = Array.from({ length: 65 }, (_, i) => ({ i })); + const streams = [createReadableStream(items), ndjsonFormatStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output.length, 2); + const firstChunkLines = output[0].split("\n").filter((l) => l.length > 0); + deepStrictEqual(firstChunkLines.length, 64); + const secondChunkLines = output[1].split("\n").filter((l) => l.length > 0); + deepStrictEqual(secondChunkLines.length, 1); +}); + +test(`${variant}: ndjsonFormatStream 2 items should produce 1 output chunk not 2`, async (_t) => { + // if(true) mutant: emits 1 item per chunk -> 2 chunks; real >= 64: via flush -> 1 chunk + const items = [{ a: 1 }, { b: 2 }]; + const streams = [createReadableStream(items), ndjsonFormatStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output.length, 1); + const lines = output[0].split("\n").filter((l) => l.length > 0); + deepStrictEqual(lines.length, 2); +}); + +// --- ndjsonParseStream: flush idx increments after multiple valid lines --- +test(`${variant}: ndjsonParseStream flush idx is 3 after 3 valid transform lines`, async (_t) => { + const parse = ndjsonParseStream(); + const streams = [ + createReadableStream('{"a":1}\n{"b":2}\n{"c":3}\nbad'), + parse, + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }, { b: 2 }, { c: 3 }]); + const { value } = parse.result(); + deepStrictEqual(value.ParseError.idx, [3]); +}); + +// --- jsonParseStream: initial buffer must be empty --- +test(`${variant}: jsonParseStream initial buffer must be empty string`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream("[1]"), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1]); + const { value } = parse.result(); + deepStrictEqual(value.NoArrayStart, undefined); +}); + +// --- jsonParseStream: inString=true when quote encountered --- +test(`${variant}: jsonParseStream handles string split across chunks`, async (_t) => { + const streams = [createReadableStream(['["hel', 'lo"]']), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, ["hello"]); +}); + +test(`${variant}: jsonParseStream string with braces does not affect depth`, async (_t) => { + const streams = [createReadableStream('["a{b}c"]'), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, ["a{b}c"]); +}); + +// --- jsonParseStream: maxBufferSize error uses addition not subtraction --- +test(`${variant}: jsonParseStream maxBufferSize error message uses addition`, async (_t) => { + // After "[1234" processed, buffer trimmed to "1234" (4 chars) + // Then ",5678]" (6 chars): 4+6=10 > 6; subtraction mutant: 4-6=-2 + let error; + try { + await pipeline([ + createReadableStream(["[1234", ",5678]"]), + jsonParseStream({ maxBufferSize: 6 }), + ]); + } catch (e) { + error = e; + } + deepStrictEqual(error?.message?.includes("maxBufferSize"), true); + deepStrictEqual(error?.message?.includes("10"), true); +}); + +// --- jsonParseStream: maxBufferSize boundary (> not >=) --- +test(`${variant}: jsonParseStream does not throw at exactly maxBufferSize`, async (_t) => { + const streams = [ + createReadableStream("[1]"), + jsonParseStream({ maxBufferSize: 3 }), + ]; + let error; + try { + await streamToArray(pipejoin(streams)); + } catch (e) { + error = e; + } + deepStrictEqual(error, undefined); +}); + +test(`${variant}: jsonParseStream throws one byte over maxBufferSize`, async (_t) => { + let error; + try { + await pipeline([ + createReadableStream(["[1", ",2]"]), + jsonParseStream({ maxBufferSize: 2 }), + ]); + } catch (e) { + error = e; + } + deepStrictEqual(error?.message?.includes("maxBufferSize"), true); +}); + +// --- jsonParseStream flush: does not emit when elementStart === -1 --- +test(`${variant}: jsonParseStream flush does not double-emit completed elements`, async (_t) => { + const streams = [createReadableStream("[1,2]"), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1, 2]); + deepStrictEqual(output.length, 2); +}); + +// --- jsonParseStream flush: emits when elementStart !== -1 and buffer non-empty --- +test(`${variant}: jsonParseStream flush emits in-progress element`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream("[42"), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [42]); +}); + +test(`${variant}: jsonParseStream flush uses elementStart offset correctly`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream("[ 42"), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [42]); +}); + +// --- jsonParseStream flush: trimmed !== "]" --- +test(`${variant}: jsonParseStream flush does not emit lone closing bracket`, async (_t) => { + const streams = [createReadableStream("[1]"), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1]); + deepStrictEqual(output.length, 1); +}); + +// --- jsonParseStream flush: buffer reset to empty --- +test(`${variant}: jsonParseStream flush resets buffer to empty string`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream("[1,2,3]"), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1, 2, 3]); + const { value } = parse.result(); + deepStrictEqual(value.NoArrayStart, undefined); + deepStrictEqual(value.ParseError, undefined); +}); + +// --- jsonParseStream: single-char chunking exercises trimFrom --- +test(`${variant}: jsonParseStream handles single-char chunking`, async (_t) => { + const jsonStr = "[1,2,3,4,5]"; + const chunks = jsonStr.split(""); + const streams = [createReadableStream(chunks), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1, 2, 3, 4, 5]); +}); + +// --- jsonParseStream: element with surrounding whitespace (trim in emitElement) --- +test(`${variant}: jsonParseStream correctly parses element with surrounding spaces`, async (_t) => { + const streams = [createReadableStream("[ 42 ]"), jsonParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [42]); +}); + +// *** Third-round targeted tests (after scanner refactor) *** // + +// --- scanner: a nested element is emitted when its closing brace returns depth +// to 0. Adjacent objects with NO comma between them can ONLY be split by the +// closing-brace emit; if that emit is skipped/disabled, the scanner would later +// try to parse the merged "{...}{...}" as one element and fail. Pins the +// `if (depth === 0)` emit (kills its `false`/block-removal/operator mutants). --- +test(`${variant}: jsonParseStream splits adjacent objects with no comma between them`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream('[{"a":1}{"b":2}]'), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1 }, { b: 2 }]); + const { value } = parse.result(); + deepStrictEqual(value.ParseError, undefined); +}); + +test(`${variant}: jsonParseStream splits adjacent arrays with no comma between them`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream("[[1,2][3,4]]"), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + [1, 2], + [3, 4], + ]); + const { value } = parse.result(); + deepStrictEqual(value.ParseError, undefined); +}); + +// --- scanner whitespace skip: leading whitespace before a value must NOT be +// included in the element's raw text (elementStart must point at the first +// non-whitespace char). With maxValueSize tuned just above the trimmed value +// but below the padded value, treating a whitespace char as content would push +// the raw length over maxValueSize and throw. One test per whitespace kind +// pins each `ch !== ` guard (space/tab/LF/CR). --- +const whitespaceLeadCases = { + space: "[ 123]", + tab: "[\t\t\t\t123]", + "line feed": "[\n\n\n\n123]", + "carriage return": "[\r\r\r\r123]", +}; +for (const [name, input] of Object.entries(whitespaceLeadCases)) { + test(`${variant}: jsonParseStream skips leading ${name} so raw value stays within maxValueSize`, async (_t) => { + // trimmed "123" = 3 chars (<= 5); padded " 123" = 7 chars (> 5). + const streams = [ + createReadableStream(input), + jsonParseStream({ maxValueSize: 5 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [123]); + }); +} + +// --- scanner buffer trim: after each fully-consumed element the processed +// portion must be trimmed from the buffer so it does not grow unbounded. With a +// small maxBufferSize, 50+ tiny elements split across chunks only fit if the +// buffer is trimmed (kills the `trimFrom` ternary and the unconditional trim +// being skipped). --- +test(`${variant}: jsonParseStream trims consumed buffer so many chunks fit a small maxBufferSize`, async (_t) => { + const chunks = ["["]; + for (let i = 0; i < 50; i++) chunks.push("1,"); + chunks.push("1]"); + const parse = jsonParseStream({ maxBufferSize: 10 }); + const output = await streamToArray( + pipejoin([createReadableStream(chunks), parse]), + ); + deepStrictEqual(output.length, 51); + deepStrictEqual(output[0], 1); + const { value } = parse.result(); + deepStrictEqual(value.ParseError, undefined); +}); + +// --- scanner trim: an in-progress element must NOT be trimmed away. When the +// buffer ends mid-element across a chunk boundary, trimFrom must be elementStart +// (not scanPos), so the partial element is preserved for the next chunk. --- +test(`${variant}: jsonParseStream preserves an in-progress element across a chunk boundary`, async (_t) => { + const parse = jsonParseStream(); + const streams = [createReadableStream(['[{"abc":', "123}]"]), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ abc: 123 }]); + const { value } = parse.result(); + deepStrictEqual(value.ParseError, undefined); +}); + +// --- scanner trim: after a chunk ends exactly on a comma (no element in +// progress, buffer fully consumed), the next chunk's leading whitespace must +// still be skipped. If the trim incorrectly left elementStart pointing at 0, +// the next element's raw text would absorb that leading whitespace and overflow +// maxValueSize. Pins the `if (elementStart !== -1)` trim-reset guard. --- +test(`${variant}: jsonParseStream keeps elementStart unset across a comma-aligned chunk boundary`, async (_t) => { + const streams = [ + createReadableStream(["[1,", " 123]"]), + jsonParseStream({ maxValueSize: 4 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1, 123]); +}); + +// --- flush: a lone closing bracket left in the buffer must NOT be emitted as an +// element (kills `trimmed !== "]"` -> `!== ""`). Construct a stream where the +// final buffer is exactly "]". --- +test(`${variant}: jsonParseStream flush ignores a trailing lone closing bracket`, async (_t) => { + // "[1" is scanned (element 1 in progress -> emitted at flush); a separate + // trailing "]" chunk arrives but, with the array still "open" at depth 0 and + // element 1 already pending, exercises the flush `]` guard. + const parse = jsonParseStream(); + const streams = [createReadableStream(["[", "1", "]"]), parse]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [1]); + const { value } = parse.result(); + deepStrictEqual(value.ParseError, undefined); +}); diff --git a/packages/json/package.json b/packages/json/package.json index f14b946..c05c577 100644 --- a/packages/json/package.json +++ b/packages/json/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/json", - "version": "0.5.0", + "version": "0.6.1", "description": "JSON and NDJSON (JSON Lines) parsing and formatting transform streams", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -63,6 +64,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/kafka/README.md b/packages/kafka/README.md new file mode 100644 index 0000000..0e6eadf --- /dev/null +++ b/packages/kafka/README.md @@ -0,0 +1,52 @@ +
+

<datastream> `kafka`

+ datastream logo +

Kafka producer and consumer streams.

+

+ GitHub Actions unit test status + GitHub Actions dast test status + GitHub Actions perf test status + GitHub Actions SAST test status + GitHub Actions lint test status +
+ npm version + npm install size + + npm weekly downloads + + npm provenance +
+ Open Source Security Foundation (OpenSSF) Scorecard + SLSA 3 + + Checked with Biome + Conventional Commits + + code coverage +

+

You can read the documentation at: https://datastream.js.org

+
+ + +## Install + +To install datastream you can use NPM: + +```bash +npm install --save @datastream/kafka +``` + + +## Documentation and examples + +For documentation and examples, refer to the main [datastream monorepo on GitHub](https://github.com/willfarrell/datastream) or [datastream official website](https://datastream.js.org). + + +## Contributing + +Everyone is very welcome to contribute to this repository. Feel free to [raise issues](https://github.com/willfarrell/datastream/issues) or to [submit Pull Requests](https://github.com/willfarrell/datastream/pulls). + + +## License + +Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell), and [datastream contributors](https://github.com/willfarrell/datastream/graphs/contributors). \ No newline at end of file diff --git a/packages/kafka/index.d.ts b/packages/kafka/index.d.ts new file mode 100644 index 0000000..6af7217 --- /dev/null +++ b/packages/kafka/index.d.ts @@ -0,0 +1,82 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import type { + DatastreamReadable, + DatastreamWritable, + StreamOptions, +} from "@datastream/core"; + +export interface KafkaConnection { + kafka: unknown; + producer: unknown | null; + consumer: unknown | null; + disconnect: () => Promise; +} + +export function kafkaConnect(options?: { + brokers?: string[]; + clientId?: string; + ssl?: unknown; + sasl?: unknown; + groupId?: string; + /** Pass `false` to skip opening a producer. */ + producer?: Record | false; + consumer?: Record; + /** Injectable Kafka constructor; defaults to a lazy `import("kafkajs")`. */ + Kafka?: new ( + options: Record, + ) => unknown; + [key: string]: unknown; +}): Promise; + +export interface KafkaMessage { + value: Uint8Array | string; + key?: Uint8Array | string; + partition?: number; + headers?: Record; + timestamp?: string; + [key: string]: unknown; +} + +export function kafkaProduceStream( + options: { + producer: unknown; + topic: string; + batchSize?: number; + acks?: number; + compression?: number; + timeout?: number; + }, + streamOptions?: StreamOptions, +): Promise>; + +export interface KafkaConsumedMessage { + topic: string; + partition: number; + offset: string; + key: Uint8Array | null; + value: Uint8Array | null; + headers: Record; + timestamp: string; +} + +export function kafkaConsumeStream( + options: { + consumer: unknown; + topics: string | string[]; + fromBeginning?: boolean; + autoCommit?: boolean; + partitionsConsumedConcurrently?: number; + signal?: AbortSignal; + }, + streamOptions?: StreamOptions, +): Promise< + DatastreamReadable & { stop: () => Promise } +>; + +declare const _default: { + connect: typeof kafkaConnect; + produceStream: typeof kafkaProduceStream; + consumeStream: typeof kafkaConsumeStream; +}; +export default _default; diff --git a/packages/kafka/index.fuzz.js b/packages/kafka/index.fuzz.js new file mode 100644 index 0000000..0c81f19 --- /dev/null +++ b/packages/kafka/index.fuzz.js @@ -0,0 +1,326 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import test from "node:test"; +import { streamToArray } from "@datastream/core"; +import { + kafkaConnect, + kafkaConsumeStream, + kafkaProduceStream, +} from "@datastream/kafka"; +import fc from "fast-check"; + +const catchError = (input, e) => { + const expectedErrors = [ + "kafkaProduceStream: producer required", + "kafkaProduceStream: topic required", + "kafkaProduceStream: batchSize must be >= 1", + "kafkaConsumeStream: consumer required", + "kafkaConsumeStream: topics required", + // Node.js stream protocol: null is the EOF sentinel and cannot be written + "May not write null values to stream", + ]; + // Also swallow normalizeMessage TypeErrors (chunk type errors) + if (expectedErrors.includes(e.message)) { + return; + } + if (e instanceof TypeError && e.message.startsWith("kafkaProduceStream:")) { + return; + } + // Node.js writable stream rejects null with ERR_STREAM_NULL_VALUES + if (e.code === "ERR_STREAM_NULL_VALUES") { + return; + } + console.error(input, e); + throw e; +}; + +// --------------------------------------------------------------------------- +// Minimal fake Kafka factory +// --------------------------------------------------------------------------- + +const makeFakeKafka = () => { + const producerMock = { + connect: async () => {}, + disconnect: async () => {}, + send: async () => {}, + }; + + const consumerMock = { + _runResolve: null, + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stop: async () => { + if (consumerMock._runResolve) consumerMock._runResolve(); + }, + run: async () => + new Promise((resolve) => { + consumerMock._runResolve = resolve; + }), + }; + + const Kafka = function (opts) { + this.opts = opts; + this.producer = () => producerMock; + this.consumer = () => consumerMock; + }; + + return { Kafka, producerMock, consumerMock }; +}; + +// --------------------------------------------------------------------------- +// fuzz: kafkaConnect +// --------------------------------------------------------------------------- + +test("fuzz kafkaConnect w/ random options", async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + brokers: fc.option( + fc.array(fc.string({ minLength: 1, maxLength: 50 }), { + minLength: 1, + maxLength: 3, + }), + ), + clientId: fc.option(fc.string({ minLength: 0, maxLength: 50 })), + groupId: fc.option(fc.string({ minLength: 0, maxLength: 50 })), + ssl: fc.option(fc.boolean()), + producer: fc.option(fc.oneof(fc.constant(false), fc.constant({}))), + }), + async (options) => { + try { + const { Kafka } = makeFakeKafka(); + const conn = await kafkaConnect({ ...options, Kafka }); + await conn.disconnect(); + } catch (e) { + catchError(options, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// --------------------------------------------------------------------------- +// fuzz: kafkaProduceStream options +// --------------------------------------------------------------------------- + +test("fuzz kafkaProduceStream w/ random options", async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + topic: fc.option(fc.string({ minLength: 0, maxLength: 50 })), + batchSize: fc.option(fc.integer({ min: -5, max: 200 })), + acks: fc.option(fc.integer({ min: -1, max: 1 })), + compression: fc.option(fc.integer({ min: 0, max: 4 })), + timeout: fc.option(fc.integer({ min: 0, max: 30_000 })), + }), + async (options) => { + try { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + ...options, + }); + stream.end(); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + }); + } catch (e) { + catchError(options, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// --------------------------------------------------------------------------- +// fuzz: kafkaProduceStream w/ random messages +// --------------------------------------------------------------------------- + +test("fuzz kafkaProduceStream w/ random messages", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.string({ minLength: 0, maxLength: 100 }), + fc.record({ + value: fc.string({ minLength: 0, maxLength: 100 }), + key: fc.option(fc.string({ minLength: 0, maxLength: 50 })), + }), + // invalid types to exercise normalizeMessage error path + fc.integer(), + fc.boolean(), + // null is excluded: it is the Node.js stream EOF sentinel and + // writing null to a Writable is a stream protocol error, not + // something kafkaProduceStream is expected to handle. + ), + { minLength: 1, maxLength: 50 }, + ), + async (messages) => { + try { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "fuzz-topic", + }); + await new Promise((resolve, reject) => { + stream.on("error", (e) => { + try { + catchError(messages, e); + resolve(); + } catch (err) { + reject(err); + } + }); + stream.on("finish", resolve); + for (const msg of messages) { + stream.write(msg); + } + stream.end(); + }); + } catch (e) { + catchError(messages, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// --------------------------------------------------------------------------- +// fuzz: kafkaConsumeStream options +// --------------------------------------------------------------------------- + +test("fuzz kafkaConsumeStream w/ random options", async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + topics: fc.option( + fc.oneof( + fc.string({ minLength: 1, maxLength: 50 }), + fc.array(fc.string({ minLength: 1, maxLength: 50 }), { + minLength: 1, + maxLength: 3, + }), + fc.constant([]), + ), + ), + fromBeginning: fc.option(fc.boolean()), + autoCommit: fc.option(fc.boolean()), + partitionsConsumedConcurrently: fc.option( + fc.integer({ min: 1, max: 8 }), + ), + }), + async (options) => { + try { + const { consumerMock } = makeFakeKafka(); + const stream = await kafkaConsumeStream({ + consumer: consumerMock, + ...options, + }); + const closePromise = new Promise((resolve) => + stream.once("close", resolve), + ); + stream.destroy(); + await closePromise; + } catch (e) { + catchError(options, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// --------------------------------------------------------------------------- +// fuzz: kafkaConsumeStream message delivery w/ random message payloads +// --------------------------------------------------------------------------- + +test("fuzz kafkaConsumeStream w/ random message payloads", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + topic: fc.string({ minLength: 1, maxLength: 50 }), + partition: fc.integer({ min: 0, max: 10 }), + offset: fc.nat().map(String), + key: fc.option(fc.string({ minLength: 0, maxLength: 50 })), + value: fc.option(fc.string({ minLength: 0, maxLength: 200 })), + timestamp: fc.nat().map(String), + }), + { minLength: 1, maxLength: 100 }, + ), + async (messages) => { + try { + let eachMessageFn; + const consumerMock = { + _runResolve: null, + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stop: async () => { + if (consumerMock._runResolve) consumerMock._runResolve(); + }, + run: async ({ eachMessage }) => { + eachMessageFn = eachMessage; + return new Promise((resolve) => { + consumerMock._runResolve = resolve; + }); + }, + }; + + const stream = await kafkaConsumeStream( + { consumer: consumerMock, topics: "fuzz-topic" }, + { highWaterMark: messages.length + 1 }, + ); + + const delivery = (async () => { + for (const msg of messages) { + await eachMessageFn({ + topic: msg.topic, + partition: msg.partition, + message: { + offset: msg.offset, + key: msg.key, + value: msg.value, + headers: {}, + timestamp: msg.timestamp, + }, + }); + } + await stream.stop(); + })(); + + await streamToArray(stream); + await delivery; + } catch (e) { + catchError(messages, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); diff --git a/packages/kafka/index.node.js b/packages/kafka/index.node.js new file mode 100644 index 0000000..ac3dbd7 --- /dev/null +++ b/packages/kafka/index.node.js @@ -0,0 +1,271 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { Readable } from "node:stream"; +import { createWritableStream } from "@datastream/core"; + +export const kafkaConnect = async ({ + brokers, + clientId, + ssl, + sasl, + groupId, + producer: producerOptions, + consumer: consumerOptions, + // Injectable Kafka constructor (defaults to kafkajs). Lets callers/tests + // supply a stub without reaching for a real broker; falls back to the lazy + // dynamic import so the production path is unchanged. + Kafka, + ...kafkaOptions +} = {}) => { + Kafka ??= (await import("kafkajs")).Kafka; + const kafka = new Kafka({ brokers, clientId, ssl, sasl, ...kafkaOptions }); + // Open a producer only if it wasn't explicitly disabled. Open a consumer + // only when a groupId is provided. Either or both may be null. + const producer = + producerOptions === false ? null : kafka.producer(producerOptions ?? {}); + const consumer = groupId + ? kafka.consumer({ groupId, ...(consumerOptions ?? {}) }) + : null; + // Connect under a guard: if the second connect fails, the first is already + // connected (open socket + background metadata/heartbeat timers) with no + // disconnect handle escaping to the caller. Tear down whatever connected + // before rethrowing so the error path leaks nothing. + try { + if (producer) await producer.connect(); + if (consumer) await consumer.connect(); + } catch (err) { + await producer?.disconnect().catch(() => {}); + await consumer?.disconnect().catch(() => {}); + throw err; + } + return { + kafka, + producer, + consumer, + disconnect: async () => { + if (producer) await producer.disconnect(); + if (consumer) await consumer.disconnect(); + }, + }; +}; + +const normalizeMessage = (chunk) => { + if (chunk instanceof Uint8Array || typeof chunk === "string") { + return { value: chunk }; + } + if (chunk && typeof chunk === "object" && "value" in chunk) { + return chunk; + } + // Note: `chunk` is never null here — Node.js Writable streams reject null + // with ERR_STREAM_NULL_VALUES before the write callback fires — so we report + // `typeof chunk` directly without a dead `chunk === null` branch. + throw new TypeError( + `kafkaProduceStream: chunk must be Uint8Array, string, or { value, ... } object (got ${typeof chunk})`, + ); +}; + +export const kafkaProduceStream = async ( + { + producer, + topic, + batchSize = 100, + acks = -1, + compression, + timeout: requestTimeout, + } = {}, + streamOptions = {}, +) => { + if (!producer) throw new TypeError("kafkaProduceStream: producer required"); + if (!topic) throw new TypeError("kafkaProduceStream: topic required"); + if (!(batchSize >= 1)) { + throw new TypeError("kafkaProduceStream: batchSize must be >= 1"); + } + + let batch = []; + const flush = async () => { + if (!batch.length) return; + // Snapshot then start a fresh batch *before* the send. Two reasons: + // 1. The snapshot stays addressable on the error path so callers can + // re-queue via `err.failedMessages` instead of losing the batch. + // 2. If kafkajs mutates `messages` (or holds a reference), we don't want + // our next write() to interleave with what's in flight. + const sending = batch; + batch = []; + try { + await producer.send({ + topic, + messages: sending, + acks, + compression, + timeout: requestTimeout, + }); + } catch (err) { + err.failedMessages = sending; + throw err; + } + }; + const write = async (chunk) => { + batch.push(normalizeMessage(chunk)); + if (batch.length >= batchSize) await flush(); + }; + const final = async () => { + await flush(); + }; + const stream = createWritableStream(write, final, streamOptions); + // Node's final() runs only on a graceful end(), never on destroy() or an + // AbortSignal-triggered teardown. Without this, a partial batch buffered + // below batchSize would be silently dropped on abort — no send, no error. + // Surface the un-sent batch on the teardown error (mirroring flush()'s + // err.failedMessages contract) so callers can recover/retry it instead of + // losing it. We wrap the default Writable destroy rather than replacing it. + const originalDestroy = stream._destroy.bind(stream); + stream._destroy = (err, cb) => { + if (batch.length) { + // The stream is being torn down: no further write()/final() can run, so + // `batch` is never read again. Hand the buffered messages straight to the + // error (mirroring flush()'s err.failedMessages contract) without a dead + // reassignment. + err ??= new Error("kafkaProduceStream: destroyed with a buffered batch"); + err.failedMessages = batch; + } + originalDestroy(err, cb); + }; + return stream; +}; + +export const kafkaConsumeStream = async ( + { + consumer, + topics, + fromBeginning = false, + autoCommit = true, + partitionsConsumedConcurrently = 1, + signal, + }, + streamOptions = {}, +) => { + if (!consumer) throw new TypeError("kafkaConsumeStream: consumer required"); + if (!topics || (Array.isArray(topics) && !topics.length)) { + throw new TypeError("kafkaConsumeStream: topics required"); + } + + const topicList = Array.isArray(topics) ? topics : [topics]; + for (const topic of topicList) { + await consumer.subscribe({ topic, fromBeginning }); + } + + // Backpressure: kafkajs awaits each eachMessage callback before fetching the + // next message on the same partition (and per-partition concurrency caps + // fan-in). We construct a Node Readable and let `.push` return false when + // the buffer is full; we then await the next `_read` (consumer pulled) before + // returning from eachMessage. That suspends kafkajs's loop end-to-end so + // backpressure reaches the broker instead of bursting through. + // With partitionsConsumedConcurrently > 1, several eachMessage callbacks can + // be parked on backpressure at the same time. Track every waiting resolver + // (not just the latest) and release them all on the next `_read`; a single + // slot would drop all but the last waiter and hang those partition loops. + const readResolvers = new Set(); + const waitForRead = () => + new Promise((resolve) => { + readResolvers.add(resolve); + }); + const releaseReaders = () => { + // Snapshot then clear before resolving so a resolver that synchronously + // re-parks lands in a fresh Set. Iterating an empty Set is already a no-op, + // so no explicit size guard is needed. + const pending = [...readResolvers]; + readResolvers.clear(); + for (const resolve of pending) resolve(); + }; + + let stopped = false; + let runPromise; + // Memoize consumer.stop() so every teardown path (explicit stop(), destroy(), + // abort) shares ONE invocation. kafkajs's consumer.stop() is not guaranteed + // safe to call twice concurrently, and stop()+later-destroy() previously + // called it twice. Best-effort: swallow errors, run at most once. + let consumerStopPromise; + const stopConsumerOnce = () => { + consumerStopPromise ??= Promise.resolve() + .then(() => consumer.stop()) + .catch(() => {}); + return consumerStopPromise; + }; + + const stream = new Readable({ + objectMode: true, + highWaterMark: streamOptions.highWaterMark ?? 100, + read() { + releaseReaders(); + }, + destroy(err, cb) { + stopped = true; + // Drop the abort listener: a destroyed stream never needs to react to + // the signal again, and on long-lived/shared signals an un-removed + // listener accumulates per stream. + if (signal) signal.removeEventListener("abort", stop); + // Release any eachMessage callbacks parked on backpressure first, so + // they observe `stopped` and return. consumer.stop() waits for in-flight + // callbacks to settle; without this release it would wait on a parked + // callback that itself can only resume once `_read` fires — a circular + // wait that deadlocks. Symmetric with stop(). + releaseReaders(); + // Best-effort consumer.stop on destroy, memoized so a prior stop() (or + // a concurrent abort) does not trigger a second consumer.stop(). + stopConsumerOnce().then(() => cb(err)); + }, + }); + + runPromise = consumer.run({ + autoCommit, + partitionsConsumedConcurrently, + eachMessage: async ({ topic, partition, message }) => { + if (stopped) return; + const wantsMore = stream.push({ + topic, + partition, + offset: message.offset, + key: message.key, + value: message.value, + headers: message.headers, + timestamp: message.timestamp, + }); + if (!wantsMore) await waitForRead(); + }, + }); + + const stop = async () => { + // No early-out on a repeat call is needed: every step below is idempotent — + // consumer.stop() is memoized via stopConsumerOnce(), removeEventListener is + // a no-op once removed, stream.push(null) is a no-op after EOF, and + // releaseReaders() is a no-op with no parked readers. Setting `stopped` + // first still makes parked eachMessage callbacks observe it and return. + stopped = true; + // Detach from the signal on every normal termination. `{ once: true }` + // only fires-and-removes when the signal actually aborts; for a normal + // stop()/drain the listener would otherwise linger forever on a + // long-lived/shared signal, accumulating one listener per consume stream. + // `stop` is the exact handler reference, so this removes cleanly. + if (signal) signal.removeEventListener("abort", stop); + await stopConsumerOnce(); + stream.push(null); + await runPromise.catch(() => {}); + // Unblock any pending eachMessage awaits so they can observe `stopped` + // and return. + releaseReaders(); + }; + + if (signal) { + if (signal.aborted) await stop(); + else signal.addEventListener("abort", stop); + } + + stream.stop = stop; + return stream; +}; + +export default { + connect: kafkaConnect, + produceStream: kafkaProduceStream, + consumeStream: kafkaConsumeStream, +}; diff --git a/packages/kafka/index.perf.js b/packages/kafka/index.perf.js new file mode 100644 index 0000000..3f6901f --- /dev/null +++ b/packages/kafka/index.perf.js @@ -0,0 +1,228 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import test from "node:test"; +import { createReadableStream, streamToArray } from "@datastream/core"; +import { + kafkaConnect, + kafkaConsumeStream, + kafkaProduceStream, +} from "@datastream/kafka"; +import { Bench } from "tinybench"; + +const time = Number(process.env.BENCH_TIME ?? 5_000); + +// --------------------------------------------------------------------------- +// Fake Kafka factory (trimmed — no spy counters needed for perf) +// --------------------------------------------------------------------------- + +const makeFakeKafka = ({ run } = {}) => { + const producerMock = { + connect: async () => {}, + disconnect: async () => {}, + send: async () => {}, + }; + + const consumerMock = { + _runResolve: null, + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stop: async () => { + if (consumerMock._runResolve) consumerMock._runResolve(); + }, + run: + run ?? + (async () => + new Promise((resolve) => { + consumerMock._runResolve = resolve; + })), + }; + + const Kafka = function (opts) { + this.opts = opts; + this.producer = () => producerMock; + this.consumer = () => consumerMock; + }; + + return { Kafka, producerMock, consumerMock }; +}; + +// --------------------------------------------------------------------------- +// perf: kafkaProduceStream +// --------------------------------------------------------------------------- + +test("perf: kafkaProduceStream", async () => { + const bench = new Bench({ name: "kafkaProduceStream", time }); + + const MSG_COUNT = 10_000; + const messages = Array.from({ length: MSG_COUNT }, (_, i) => ({ + value: `message-${i}`, + key: `key-${i}`, + })); + + bench.add(`produce ${MSG_COUNT} messages (batchSize default)`, async () => { + const { Kafka } = makeFakeKafka(); + const { producer } = await kafkaConnect({ Kafka }); + const stream = await kafkaProduceStream({ producer, topic: "perf-topic" }); + const readable = createReadableStream(messages); + const writeAll = new Promise((resolve, reject) => { + readable.on("data", (chunk) => { + stream.write(chunk); + }); + readable.on("end", () => { + stream.end(); + }); + stream.on("finish", resolve); + stream.on("error", reject); + }); + await writeAll; + }); + + bench.add(`produce ${MSG_COUNT} messages (batchSize 1000)`, async () => { + const { Kafka } = makeFakeKafka(); + const { producer } = await kafkaConnect({ Kafka }); + const stream = await kafkaProduceStream({ + producer, + topic: "perf-topic", + batchSize: 1_000, + }); + const readable = createReadableStream(messages); + const writeAll = new Promise((resolve, reject) => { + readable.on("data", (chunk) => { + stream.write(chunk); + }); + readable.on("end", () => { + stream.end(); + }); + stream.on("finish", resolve); + stream.on("error", reject); + }); + await writeAll; + }); + + bench.add(`produce ${MSG_COUNT} string messages`, async () => { + const { Kafka } = makeFakeKafka(); + const { producer } = await kafkaConnect({ Kafka }); + const stream = await kafkaProduceStream({ producer, topic: "perf-topic" }); + const strMessages = Array.from({ length: MSG_COUNT }, (_, i) => `msg-${i}`); + const readable = createReadableStream(strMessages); + const writeAll = new Promise((resolve, reject) => { + readable.on("data", (chunk) => { + stream.write(chunk); + }); + readable.on("end", () => { + stream.end(); + }); + stream.on("finish", resolve); + stream.on("error", reject); + }); + await writeAll; + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +// --------------------------------------------------------------------------- +// perf: kafkaConsumeStream +// --------------------------------------------------------------------------- + +test("perf: kafkaConsumeStream", async () => { + const bench = new Bench({ name: "kafkaConsumeStream", time }); + + const MSG_COUNT = 10_000; + + bench.add(`consume ${MSG_COUNT} messages`, async () => { + let eachMessageFn; + const consumerMock = { + _runResolve: null, + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stop: async () => { + if (consumerMock._runResolve) consumerMock._runResolve(); + }, + run: async ({ eachMessage }) => { + eachMessageFn = eachMessage; + return new Promise((resolve) => { + consumerMock._runResolve = resolve; + }); + }, + }; + + const stream = await kafkaConsumeStream( + { consumer: consumerMock, topics: "perf-topic" }, + { highWaterMark: MSG_COUNT }, + ); + + // Drive messages then stop + const delivery = (async () => { + for (let i = 0; i < MSG_COUNT; i++) { + await eachMessageFn({ + topic: "perf-topic", + partition: 0, + message: { + offset: String(i), + key: `key-${i}`, + value: `value-${i}`, + headers: {}, + timestamp: String(Date.now()), + }, + }); + } + await stream.stop(); + })(); + + await streamToArray(stream); + await delivery; + }); + + bench.add(`consume ${MSG_COUNT} messages (highWaterMark 100)`, async () => { + let eachMessageFn; + const consumerMock = { + _runResolve: null, + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stop: async () => { + if (consumerMock._runResolve) consumerMock._runResolve(); + }, + run: async ({ eachMessage }) => { + eachMessageFn = eachMessage; + return new Promise((resolve) => { + consumerMock._runResolve = resolve; + }); + }, + }; + + const stream = await kafkaConsumeStream( + { consumer: consumerMock, topics: "perf-topic" }, + { highWaterMark: 100 }, + ); + + const delivery = (async () => { + for (let i = 0; i < MSG_COUNT; i++) { + await eachMessageFn({ + topic: "perf-topic", + partition: 0, + message: { + offset: String(i), + key: `key-${i}`, + value: `value-${i}`, + headers: {}, + timestamp: String(Date.now()), + }, + }); + } + await stream.stop(); + })(); + + await streamToArray(stream); + await delivery; + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); diff --git a/packages/kafka/index.test.js b/packages/kafka/index.test.js new file mode 100644 index 0000000..ae4eb1b --- /dev/null +++ b/packages/kafka/index.test.js @@ -0,0 +1,1596 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert"; +import test from "node:test"; +import { streamToArray } from "@datastream/core"; + +import kafkaDefault, { + kafkaConnect, + kafkaConsumeStream, + kafkaProduceStream, +} from "@datastream/kafka"; + +// --------------------------------------------------------------------------- +// Fake Kafka factory helpers +// --------------------------------------------------------------------------- + +/** Build a simple fake kafkajs-like Kafka constructor. */ +const makeFakeKafka = ({ + connectProducer = async () => {}, + disconnectProducer = async () => {}, + sendMessages = async () => {}, + connectConsumer = async () => {}, + disconnectConsumer = async () => {}, + subscribe = async () => {}, + // run: caller can override; default drives one message then resolves + run, +} = {}) => { + const producerMock = { + connectCalled: 0, + disconnectCalled: 0, + sendArgs: [], + connect: async () => { + producerMock.connectCalled++; + return connectProducer(); + }, + disconnect: async () => { + producerMock.disconnectCalled++; + return disconnectProducer(); + }, + send: async (opts) => { + producerMock.sendArgs.push(opts); + return sendMessages(opts); + }, + }; + + const consumerMock = { + connectCalled: 0, + disconnectCalled: 0, + subscribeCalls: [], + stopCalled: 0, + _runResolve: null, + connect: async () => { + consumerMock.connectCalled++; + return connectConsumer(); + }, + disconnect: async () => { + consumerMock.disconnectCalled++; + return disconnectConsumer(); + }, + subscribe: async (opts) => { + consumerMock.subscribeCalls.push(opts); + return subscribe(opts); + }, + stop: async () => { + consumerMock.stopCalled++; + if (consumerMock._runResolve) consumerMock._runResolve(); + }, + run: + run ?? + (async () => { + // Return a promise that resolves when stop() is called + return new Promise((resolve) => { + consumerMock._runResolve = resolve; + }); + }), + }; + + const Kafka = function (opts) { + this.opts = opts; + this.producer = (opts) => { + producerMock.producerOpts = opts; + return producerMock; + }; + this.consumer = (opts) => { + consumerMock.consumerOpts = opts; + return consumerMock; + }; + }; + + return { Kafka, producerMock, consumerMock }; +}; + +// --------------------------------------------------------------------------- +// kafkaConnect +// --------------------------------------------------------------------------- + +test("kafkaConnect: returns kafka, producer, consumer, disconnect", async () => { + const { Kafka, producerMock, consumerMock } = makeFakeKafka(); + const result = await kafkaConnect({ groupId: "g1", Kafka }); + ok(result.kafka, "has kafka"); + ok(result.producer === producerMock, "has producer"); + ok(result.consumer === consumerMock, "has consumer"); + ok(typeof result.disconnect === "function", "has disconnect"); +}); + +test("kafkaConnect: passes brokers/clientId/ssl/sasl to Kafka constructor", async () => { + let capturedOpts; + const FakeKafka = function (opts) { + capturedOpts = opts; + this.producer = () => ({ + connect: async () => {}, + disconnect: async () => {}, + }); + this.consumer = () => ({ + connect: async () => {}, + disconnect: async () => {}, + }); + }; + await kafkaConnect({ + brokers: ["b1"], + clientId: "cid", + ssl: true, + sasl: { mechanism: "plain" }, + groupId: "g1", + Kafka: FakeKafka, + }); + deepStrictEqual(capturedOpts.brokers, ["b1"]); + strictEqual(capturedOpts.clientId, "cid"); + strictEqual(capturedOpts.ssl, true); + deepStrictEqual(capturedOpts.sasl, { mechanism: "plain" }); +}); + +test("kafkaConnect: producer is null when producerOptions === false", async () => { + const { Kafka } = makeFakeKafka(); + const result = await kafkaConnect({ producer: false, Kafka }); + strictEqual(result.producer, null, "producer should be null"); +}); + +test("kafkaConnect: consumer is null when groupId is not provided", async () => { + const { Kafka } = makeFakeKafka(); + const result = await kafkaConnect({ Kafka }); + strictEqual(result.consumer, null, "consumer should be null"); +}); + +test("kafkaConnect: producer connects when producerOptions is truthy", async () => { + const { Kafka, producerMock } = makeFakeKafka(); + await kafkaConnect({ Kafka }); + strictEqual(producerMock.connectCalled, 1, "producer.connect called once"); +}); + +test("kafkaConnect: consumer connects when groupId is provided", async () => { + const { Kafka, consumerMock } = makeFakeKafka(); + await kafkaConnect({ groupId: "g1", Kafka }); + strictEqual(consumerMock.connectCalled, 1, "consumer.connect called once"); +}); + +test("kafkaConnect: producer not connected when producer === false", async () => { + const { Kafka, producerMock } = makeFakeKafka(); + await kafkaConnect({ producer: false, Kafka }); + strictEqual(producerMock.connectCalled, 0, "producer.connect not called"); +}); + +test("kafkaConnect: consumer not connected when no groupId", async () => { + const { Kafka, consumerMock } = makeFakeKafka(); + await kafkaConnect({ Kafka }); + strictEqual(consumerMock.connectCalled, 0, "consumer.connect not called"); +}); + +test("kafkaConnect: disconnect() calls producer.disconnect when producer exists", async () => { + const { Kafka, producerMock } = makeFakeKafka(); + const conn = await kafkaConnect({ Kafka }); + await conn.disconnect(); + strictEqual(producerMock.disconnectCalled, 1); +}); + +test("kafkaConnect: disconnect() calls consumer.disconnect when consumer exists", async () => { + const { Kafka, consumerMock } = makeFakeKafka(); + const conn = await kafkaConnect({ groupId: "g1", Kafka }); + await conn.disconnect(); + strictEqual(consumerMock.disconnectCalled, 1); +}); + +test("kafkaConnect: disconnect() does not call producer.disconnect when producer is null", async () => { + const { Kafka, producerMock } = makeFakeKafka(); + const conn = await kafkaConnect({ producer: false, Kafka }); + await conn.disconnect(); + strictEqual(producerMock.disconnectCalled, 0); +}); + +test("kafkaConnect: disconnect() does not call consumer.disconnect when consumer is null", async () => { + const { Kafka, consumerMock } = makeFakeKafka(); + const conn = await kafkaConnect({ Kafka }); + await conn.disconnect(); + strictEqual(consumerMock.disconnectCalled, 0); +}); + +test("kafkaConnect: producer connect error triggers teardown and rethrows", async () => { + const { Kafka, producerMock } = makeFakeKafka({ + connectProducer: async () => { + throw new Error("producer connect failed"); + }, + }); + await rejects(async () => kafkaConnect({ Kafka }), /producer connect failed/); + strictEqual(producerMock.disconnectCalled, 1); +}); + +test("kafkaConnect: consumer connect error triggers teardown and rethrows", async () => { + const { Kafka, producerMock, consumerMock } = makeFakeKafka({ + connectConsumer: async () => { + throw new Error("consumer connect failed"); + }, + }); + await rejects( + async () => kafkaConnect({ groupId: "g1", Kafka }), + /consumer connect failed/, + ); + strictEqual(producerMock.disconnectCalled, 1); + strictEqual(consumerMock.disconnectCalled, 1); +}); + +test("kafkaConnect: passes consumerOptions alongside groupId", async () => { + const { Kafka, consumerMock } = makeFakeKafka(); + await kafkaConnect({ + groupId: "g1", + consumer: { sessionTimeout: 5000 }, + Kafka, + }); + deepStrictEqual(consumerMock.consumerOpts, { + groupId: "g1", + sessionTimeout: 5000, + }); +}); + +test("kafkaConnect: default exports connect/produceStream/consumeStream", () => { + ok(typeof kafkaDefault.connect === "function"); + ok(typeof kafkaDefault.produceStream === "function"); + ok(typeof kafkaDefault.consumeStream === "function"); +}); + +// --------------------------------------------------------------------------- +// kafkaProduceStream: validation +// --------------------------------------------------------------------------- + +test("kafkaProduceStream: throws when producer is missing", async () => { + await rejects( + async () => kafkaProduceStream({ topic: "t" }), + /kafkaProduceStream: producer required/, + ); +}); + +test("kafkaProduceStream: throws TypeError when producer is missing", async () => { + await rejects(async () => kafkaProduceStream({ topic: "t" }), TypeError); +}); + +test("kafkaProduceStream: throws when topic is missing", async () => { + const { producerMock } = makeFakeKafka(); + await rejects( + async () => kafkaProduceStream({ producer: producerMock }), + /kafkaProduceStream: topic required/, + ); +}); + +test("kafkaProduceStream: throws TypeError when topic is missing", async () => { + const { producerMock } = makeFakeKafka(); + await rejects( + async () => kafkaProduceStream({ producer: producerMock }), + TypeError, + ); +}); + +test("kafkaProduceStream: throws when batchSize is 0", async () => { + const { producerMock } = makeFakeKafka(); + await rejects( + async () => + kafkaProduceStream({ producer: producerMock, topic: "t", batchSize: 0 }), + /kafkaProduceStream: batchSize must be >= 1/, + ); +}); + +test("kafkaProduceStream: throws TypeError when batchSize < 1", async () => { + const { producerMock } = makeFakeKafka(); + await rejects( + async () => + kafkaProduceStream({ producer: producerMock, topic: "t", batchSize: 0 }), + TypeError, + ); +}); + +test("kafkaProduceStream: accepts batchSize === 1 without throwing", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + batchSize: 1, + }); + ok(stream, "stream created"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); +}); + +// --------------------------------------------------------------------------- +// kafkaProduceStream: normalizeMessage +// --------------------------------------------------------------------------- + +test("kafkaProduceStream: sends string chunk wrapped as { value }", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + stream.write("hello"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs.length, 1); + deepStrictEqual(producerMock.sendArgs[0].messages[0], { value: "hello" }); +}); + +test("kafkaProduceStream: sends Uint8Array chunk wrapped as { value }", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + const buf = new Uint8Array([1, 2, 3]); + stream.write(buf); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs.length, 1); + deepStrictEqual(producerMock.sendArgs[0].messages[0], { value: buf }); +}); + +test("kafkaProduceStream: passes through { value, key, headers } object unchanged", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + const msg = { value: "v", key: "k", headers: { h: "1" } }; + stream.write(msg); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + deepStrictEqual(producerMock.sendArgs[0].messages[0], msg); +}); + +test("kafkaProduceStream: emits error on invalid chunk (boolean false — not Uint8Array/string/object-with-value)", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + const errorPromise = new Promise((resolve) => stream.once("error", resolve)); + stream.write(false); + const err = await errorPromise; + ok(err instanceof TypeError, "error is TypeError"); + ok(err.message.includes("boolean"), "error message includes actual type"); +}); + +test("kafkaProduceStream: error message includes actual type for non-null invalid chunk", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + const errorPromise = new Promise((resolve) => stream.once("error", resolve)); + stream.write(42); + const err = await errorPromise; + ok(err.message.includes("number"), "error message includes 'number'"); +}); + +// --------------------------------------------------------------------------- +// kafkaProduceStream: batching +// --------------------------------------------------------------------------- + +test("kafkaProduceStream: batches messages and flushes at batchSize", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + batchSize: 3, + }); + for (let i = 0; i < 6; i++) stream.write(`msg${i}`); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + // 2 batches of 3 + strictEqual(producerMock.sendArgs.length, 2); + strictEqual(producerMock.sendArgs[0].messages.length, 3); + strictEqual(producerMock.sendArgs[1].messages.length, 3); +}); + +test("kafkaProduceStream: partial batch flushed on end", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + batchSize: 10, + }); + stream.write("a"); + stream.write("b"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs.length, 1); + strictEqual(producerMock.sendArgs[0].messages.length, 2); +}); + +test("kafkaProduceStream: empty stream sends no messages", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs.length, 0); +}); + +test("kafkaProduceStream: sends with correct topic", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "my-topic", + }); + stream.write("msg"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs[0].topic, "my-topic"); +}); + +test("kafkaProduceStream: sends with acks default -1", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + stream.write("msg"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs[0].acks, -1); +}); + +test("kafkaProduceStream: sends with custom acks value", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + acks: 1, + }); + stream.write("msg"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs[0].acks, 1); +}); + +test("kafkaProduceStream: forwards compression to producer.send", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + compression: 2, + }); + stream.write("msg"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs[0].compression, 2); +}); + +test("kafkaProduceStream: forwards timeout to producer.send", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + timeout: 5000, + }); + stream.write("msg"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs[0].timeout, 5000); +}); + +// --------------------------------------------------------------------------- +// kafkaProduceStream: send error attaches failedMessages +// --------------------------------------------------------------------------- + +test("kafkaProduceStream: send error attaches failedMessages", async () => { + const sendErr = new Error("send failed"); + const { producerMock } = makeFakeKafka({ + sendMessages: async () => { + throw sendErr; + }, + }); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + const errPromise = new Promise((resolve) => stream.once("error", resolve)); + stream.write("msg"); + stream.end(); + const err = await errPromise; + ok(Array.isArray(err.failedMessages), "failedMessages is array"); + strictEqual(err.failedMessages.length, 1); + strictEqual(err.failedMessages[0].value, "msg"); +}); + +// --------------------------------------------------------------------------- +// kafkaProduceStream: destroy with buffered batch +// --------------------------------------------------------------------------- + +test("kafkaProduceStream: destroy with buffered batch attaches failedMessages to error", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + batchSize: 100, + }); + const errPromise = new Promise((resolve) => stream.once("error", resolve)); + // Await both writes so they land in the batch before destroy + const w1Done = new Promise((resolve) => { + stream.write("msg1", resolve); + }); + const w2Done = new Promise((resolve) => { + stream.write("msg2", resolve); + }); + await w1Done; + await w2Done; + stream.destroy(); + const err = await errPromise; + ok( + err.message.includes("destroyed with a buffered batch"), + `expected destroy error, got: ${err.message}`, + ); + ok(Array.isArray(err.failedMessages), "failedMessages is array"); + strictEqual(err.failedMessages.length, 2); +}); + +test("kafkaProduceStream: destroy with existing error attaches failedMessages", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + batchSize: 100, + }); + const errPromise = new Promise((resolve) => stream.once("error", resolve)); + // Await write so msg lands in batch before destroy + const wDone = new Promise((resolve) => { + stream.write("msg", resolve); + }); + await wDone; + const existingErr = new Error("existing error"); + stream.destroy(existingErr); + const err = await errPromise; + strictEqual(err.message, "existing error"); + ok(Array.isArray(err.failedMessages)); + strictEqual(err.failedMessages.length, 1); +}); + +test("kafkaProduceStream: destroy with empty batch does not set failedMessages", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + batchSize: 100, + }); + // No writes — batch is empty + const closePromise = new Promise((resolve) => stream.on("close", resolve)); + stream.destroy(); + await closePromise; + // No error should have been emitted with failedMessages + // since batch was empty we never set failedMessages +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: validation +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: throws when consumer is missing", async () => { + await rejects( + async () => kafkaConsumeStream({ topics: "t" }), + /kafkaConsumeStream: consumer required/, + ); +}); + +test("kafkaConsumeStream: throws TypeError when consumer is missing", async () => { + await rejects(async () => kafkaConsumeStream({ topics: "t" }), TypeError); +}); + +test("kafkaConsumeStream: throws when topics is missing", async () => { + const { consumerMock } = makeFakeKafka(); + await rejects( + async () => kafkaConsumeStream({ consumer: consumerMock }), + /kafkaConsumeStream: topics required/, + ); +}); + +test("kafkaConsumeStream: throws TypeError when topics is missing", async () => { + const { consumerMock } = makeFakeKafka(); + await rejects( + async () => kafkaConsumeStream({ consumer: consumerMock }), + TypeError, + ); +}); + +test("kafkaConsumeStream: throws when topics is an empty array", async () => { + const { consumerMock } = makeFakeKafka(); + await rejects( + async () => kafkaConsumeStream({ consumer: consumerMock, topics: [] }), + /kafkaConsumeStream: topics required/, + ); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: subscribe +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: subscribes to a single string topic", async () => { + const { consumerMock } = makeFakeKafka(); + const stream = await kafkaConsumeStream({ + consumer: consumerMock, + topics: "my-topic", + }); + // Subscribe happens during stream creation; assert then clean up + deepStrictEqual(consumerMock.subscribeCalls, [ + { topic: "my-topic", fromBeginning: false }, + ]); + const closePromise = new Promise((r) => stream.once("close", r)); + stream.destroy(); + await closePromise; +}); + +test("kafkaConsumeStream: subscribes to multiple topics in array", async () => { + const { consumerMock } = makeFakeKafka(); + const stream = await kafkaConsumeStream({ + consumer: consumerMock, + topics: ["topic-a", "topic-b"], + }); + deepStrictEqual(consumerMock.subscribeCalls, [ + { topic: "topic-a", fromBeginning: false }, + { topic: "topic-b", fromBeginning: false }, + ]); + const closePromise = new Promise((r) => stream.once("close", r)); + stream.destroy(); + await closePromise; +}); + +test("kafkaConsumeStream: passes fromBeginning=true to subscribe", async () => { + const { consumerMock } = makeFakeKafka(); + const stream = await kafkaConsumeStream({ + consumer: consumerMock, + topics: "t", + fromBeginning: true, + }); + strictEqual(consumerMock.subscribeCalls[0].fromBeginning, true); + const closePromise = new Promise((r) => stream.once("close", r)); + stream.destroy(); + await closePromise; +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: message delivery +// --------------------------------------------------------------------------- + +/** Helper: build a fake consumer that delivers messages via run(). */ +const makeMessageConsumer = (_messages) => { + const consumerMock = { + subscribeCalls: [], + stopCalled: 0, + _eachMessage: null, + _runResolve: null, + connect: async () => {}, + disconnect: async () => {}, + subscribe: async (opts) => { + consumerMock.subscribeCalls.push(opts); + }, + stop: async () => { + consumerMock.stopCalled++; + if (consumerMock._runResolve) consumerMock._runResolve(); + }, + run: async ({ eachMessage }) => { + consumerMock._eachMessage = eachMessage; + return new Promise((resolve) => { + consumerMock._runResolve = resolve; + }); + }, + }; + return consumerMock; +}; + +test("kafkaConsumeStream: delivers messages from eachMessage to stream", async () => { + const msgs = [ + { + topic: "t", + partition: 0, + message: { + offset: "0", + key: "k", + value: "v", + headers: {}, + timestamp: "0", + }, + }, + { + topic: "t", + partition: 0, + message: { + offset: "1", + key: "k2", + value: "v2", + headers: {}, + timestamp: "1", + }, + }, + ]; + const consumer = makeMessageConsumer(msgs); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + + // Deliver messages then stop + setTimeout(async () => { + for (const m of msgs) await consumer._eachMessage(m); + await stream.stop(); + }, 0); + + const received = await streamToArray(stream); + strictEqual(received.length, 2); + strictEqual(received[0].value, "v"); + strictEqual(received[1].value, "v2"); +}); + +test("kafkaConsumeStream: message object includes topic, partition, offset, key, value, headers, timestamp", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + + setTimeout(async () => { + await consumer._eachMessage({ + topic: "my-topic", + partition: 3, + message: { + offset: "42", + key: "mykey", + value: "myval", + headers: { x: "1" }, + timestamp: "9999", + }, + }); + await stream.stop(); + }, 0); + + const [msg] = await streamToArray(stream); + strictEqual(msg.topic, "my-topic"); + strictEqual(msg.partition, 3); + strictEqual(msg.offset, "42"); + strictEqual(msg.key, "mykey"); + strictEqual(msg.value, "myval"); + deepStrictEqual(msg.headers, { x: "1" }); + strictEqual(msg.timestamp, "9999"); +}); + +test("kafkaConsumeStream: stop() ends the readable stream", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + + const endedPromise = new Promise((resolve) => stream.once("end", resolve)); + stream.resume(); + setTimeout(() => stream.stop(), 0); + await endedPromise; +}); + +test("kafkaConsumeStream: stop() is idempotent (calling twice doesn't call consumer.stop twice)", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + + await stream.stop(); + await stream.stop(); // second call should be no-op + strictEqual(consumer.stopCalled, 1, "consumer.stop called only once"); +}); + +test("kafkaConsumeStream: eachMessage after stopped is a no-op (does not push)", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + + const receivedChunks = []; + stream.on("data", (chunk) => receivedChunks.push(chunk)); + + await stream.stop(); + // After stop, eachMessage should return without pushing + await consumer._eachMessage({ + topic: "t", + partition: 0, + message: { + offset: "0", + key: null, + value: "dropped", + headers: {}, + timestamp: "0", + }, + }); + + // Give event loop a tick to see if anything was pushed + await new Promise((resolve) => setTimeout(resolve, 10)); + strictEqual(receivedChunks.length, 0, "no chunks after stop"); +}); + +test("kafkaConsumeStream: passes autoCommit to consumer.run", async () => { + let capturedOpts; + const consumer = { + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stop: async () => { + if (consumer._runResolve) consumer._runResolve(); + }, + run: async (opts) => { + capturedOpts = opts; + return new Promise((resolve) => { + consumer._runResolve = resolve; + }); + }, + }; + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + autoCommit: false, + }); + await stream.stop(); + strictEqual(capturedOpts.autoCommit, false); + stream.destroy(); +}); + +test("kafkaConsumeStream: passes partitionsConsumedConcurrently to consumer.run", async () => { + let capturedOpts; + const consumer = { + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stop: async () => { + if (consumer._runResolve) consumer._runResolve(); + }, + run: async (opts) => { + capturedOpts = opts; + return new Promise((resolve) => { + consumer._runResolve = resolve; + }); + }, + }; + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + partitionsConsumedConcurrently: 4, + }); + await stream.stop(); + strictEqual(capturedOpts.partitionsConsumedConcurrently, 4); + stream.destroy(); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: abort signal +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: already-aborted signal calls stop immediately", async () => { + const consumer = makeMessageConsumer([]); + const ac = new AbortController(); + ac.abort(); + + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + signal: ac.signal, + }); + // Stream should be ended because signal was already aborted + const endedPromise = new Promise((resolve) => { + if (stream.readableEnded) return resolve(); + stream.once("end", resolve); + }); + stream.resume(); // drain to trigger end event + await endedPromise; +}); + +test("kafkaConsumeStream: abort signal triggers stop", async () => { + const consumer = makeMessageConsumer([]); + const ac = new AbortController(); + + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + signal: ac.signal, + }); + const endedPromise = new Promise((resolve) => stream.once("end", resolve)); + stream.resume(); // start flowing + ac.abort(); + await endedPromise; + strictEqual(consumer.stopCalled, 1); +}); + +test("kafkaConsumeStream: destroy() calls consumer.stop", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + + const closePromise = new Promise((resolve) => stream.once("close", resolve)); + stream.destroy(); + await closePromise; + strictEqual(consumer.stopCalled, 1); +}); + +test("kafkaConsumeStream: destroy() removes abort listener (signal guard)", async () => { + const consumer = makeMessageConsumer([]); + const ac = new AbortController(); + + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + signal: ac.signal, + }); + const closePromise = new Promise((resolve) => stream.once("close", resolve)); + stream.destroy(); + await closePromise; + + // Aborting after destroy should not call consumer.stop a second time + ac.abort(); + await new Promise((resolve) => setTimeout(resolve, 10)); + strictEqual(consumer.stopCalled, 1, "consumer.stop called only once"); +}); + +test("kafkaConsumeStream: stop() removes abort listener so abort after stop is a no-op", async () => { + const consumer = makeMessageConsumer([]); + const ac = new AbortController(); + + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + signal: ac.signal, + }); + await stream.stop(); + + // Aborting after stop should not call consumer.stop again + ac.abort(); + await new Promise((resolve) => setTimeout(resolve, 10)); + strictEqual(consumer.stopCalled, 1); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: highWaterMark option +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: respects highWaterMark streamOption", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream( + { consumer, topics: "t" }, + { highWaterMark: 5 }, + ); + strictEqual(stream.readableHighWaterMark, 5); + await stream.stop(); + stream.destroy(); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: backpressure (wantsMore === false triggers waitForRead) +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: backpressure suspends eachMessage until _read is called", async () => { + const consumer = makeMessageConsumer([]); + // Use a very small HWM so backpressure kicks in quickly + const stream = await kafkaConsumeStream( + { consumer, topics: "t" }, + { highWaterMark: 1 }, + ); + + let msg2Sent = false; + const deliveryDone = (async () => { + // Send first message - should go through + await consumer._eachMessage({ + topic: "t", + partition: 0, + message: { + offset: "0", + key: null, + value: "v1", + headers: {}, + timestamp: "0", + }, + }); + // Send second message - may block until stream reads first + await consumer._eachMessage({ + topic: "t", + partition: 0, + message: { + offset: "1", + key: null, + value: "v2", + headers: {}, + timestamp: "1", + }, + }); + msg2Sent = true; + await stream.stop(); + })(); + + const received = await streamToArray(stream); + await deliveryDone; + ok(msg2Sent, "second message was eventually delivered"); + strictEqual(received.length, 2); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: stopConsumerOnce memoization +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: stopConsumerOnce is memoized (stop + destroy only calls consumer.stop once)", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + + const closePromise = new Promise((resolve) => stream.once("close", resolve)); + // Call stop() then destroy() — both trigger stopConsumerOnce + await stream.stop(); + stream.destroy(); + await closePromise; + + strictEqual(consumer.stopCalled, 1, "consumer.stop called exactly once"); +}); + +// --------------------------------------------------------------------------- +// kafkaConnect: producerOptions passed to kafka.producer() +// --------------------------------------------------------------------------- + +test("kafkaConnect: producerOptions are passed through to kafka.producer()", async () => { + let capturedProducerOpts; + const FakeKafka = function () { + this.producer = (opts) => { + capturedProducerOpts = opts; + return { connect: async () => {}, disconnect: async () => {} }; + }; + this.consumer = () => ({ + connect: async () => {}, + disconnect: async () => {}, + }); + }; + const producerOptions = { transactionalId: "tx-1", idempotent: true }; + await kafkaConnect({ producer: producerOptions, Kafka: FakeKafka }); + deepStrictEqual(capturedProducerOpts, producerOptions); +}); + +test("kafkaConnect: producerOptions ?? {} default passes empty object to kafka.producer()", async () => { + let capturedProducerOpts; + const FakeKafka = function () { + this.producer = (opts) => { + capturedProducerOpts = opts; + return { connect: async () => {}, disconnect: async () => {} }; + }; + this.consumer = () => ({ + connect: async () => {}, + disconnect: async () => {}, + }); + }; + // No producerOptions provided — should default to {} + await kafkaConnect({ Kafka: FakeKafka }); + deepStrictEqual(capturedProducerOpts, {}); +}); + +// --------------------------------------------------------------------------- +// kafkaConnect: optional chaining in error teardown +// --------------------------------------------------------------------------- + +test("kafkaConnect: consumer connect error with producer===false does not throw in teardown", async () => { + // producer is null; in the catch block, producer?.disconnect() must NOT throw + const FakeKafka = function () { + this.producer = () => ({ + connect: async () => {}, + disconnect: async () => {}, + }); + this.consumer = () => ({ + connect: async () => { + throw new Error("consumer connect failed"); + }, + disconnect: async () => {}, + }); + }; + // producer: false => producer is null => in catch block producer?.disconnect() is safe + await rejects( + async () => + kafkaConnect({ producer: false, groupId: "g1", Kafka: FakeKafka }), + /consumer connect failed/, + "should rethrow consumer connect error", + ); +}); + +// --------------------------------------------------------------------------- +// kafkaProduceStream: null chunk error message specifics +// --------------------------------------------------------------------------- + +test("kafkaProduceStream: error message includes type 'number' for numeric chunk", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + const errPromise = new Promise((resolve) => stream.once("error", resolve)); + stream.write(123); + const err = await errPromise; + ok( + err.message.includes("number"), + "error message includes 'number' (not empty string)", + ); + ok(err.message.length > 10, "error message is not empty"); +}); + +test("kafkaProduceStream: normalizeMessage: object without value property throws TypeError with type 'object'", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + }); + const errPromise = new Promise((resolve) => stream.once("error", resolve)); + stream.write({ noValue: "here" }); + const err = await errPromise; + ok(err instanceof TypeError, "should be TypeError"); + ok(err.message.includes("object"), "message includes type 'object'"); +}); + +// --------------------------------------------------------------------------- +// kafkaProduceStream: batch reset is correct after flush +// --------------------------------------------------------------------------- + +test("kafkaProduceStream: after batch flush, next messages go into a fresh batch", async () => { + const { producerMock } = makeFakeKafka(); + const stream = await kafkaProduceStream({ + producer: producerMock, + topic: "t", + batchSize: 2, + }); + // 4 messages = 2 full batches + stream.write("a"); + stream.write("b"); + stream.write("c"); + stream.write("d"); + stream.end(); + await new Promise((resolve) => stream.on("finish", resolve)); + strictEqual(producerMock.sendArgs.length, 2, "two separate flushes"); + // First batch: a, b + strictEqual(producerMock.sendArgs[0].messages[0].value, "a"); + strictEqual(producerMock.sendArgs[0].messages[1].value, "b"); + // Second batch: c, d (not ["Stryker was here", "c", "d"]) + strictEqual(producerMock.sendArgs[1].messages.length, 2); + strictEqual(producerMock.sendArgs[1].messages[0].value, "c"); + strictEqual(producerMock.sendArgs[1].messages[1].value, "d"); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: autoCommit default true is passed to consumer.run +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: autoCommit defaults to true and is passed to consumer.run", async () => { + let capturedOpts; + const consumer = { + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stop: async () => { + if (consumer._runResolve) consumer._runResolve(); + }, + run: async (opts) => { + capturedOpts = opts; + return new Promise((resolve) => { + consumer._runResolve = resolve; + }); + }, + }; + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + // Default autoCommit should be true + strictEqual(capturedOpts.autoCommit, true); + const closePromise = new Promise((r) => stream.once("close", r)); + stream.destroy(); + await closePromise; +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: destroy sets stopped=true so eachMessage is a no-op +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: destroy() sets stopped=true so eachMessage after destroy is a no-op", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + + const closePromise = new Promise((resolve) => stream.once("close", resolve)); + stream.destroy(); + await closePromise; + + // Try delivering a message after destroy — should be a no-op (stopped===true) + if (consumer._eachMessage) { + const _chunksPushed = []; + // The stream is already destroyed so push won't add data; just verify no throw + await consumer._eachMessage({ + topic: "t", + partition: 0, + message: { + offset: "99", + key: null, + value: "late", + headers: {}, + timestamp: "0", + }, + }); + } + await new Promise((resolve) => setTimeout(resolve, 10)); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: abort event listener uses "abort" event name +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: abort event listener is on 'abort' event (not '')", async () => { + const consumer = makeMessageConsumer([]); + const listeners = []; + const mockSignal = { + aborted: false, + addEventListener: (event, fn) => { + listeners.push({ event, fn }); + }, + removeEventListener: (event, fn) => { + const idx = listeners.findIndex((l) => l.event === event && l.fn === fn); + if (idx !== -1) listeners.splice(idx, 1); + }, + }; + + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + signal: mockSignal, + }); + // Listener was registered on 'abort' event + strictEqual(listeners.length, 1, "one listener added"); + strictEqual(listeners[0].event, "abort", "listener is on 'abort' event"); + + // stop() removes it + await stream.stop(); + strictEqual(listeners.length, 0, "listener removed after stop"); + stream.destroy(); +}); + +test("kafkaConsumeStream: destroy() removes 'abort' listener (correct event name)", async () => { + const consumer = makeMessageConsumer([]); + const listeners = []; + const mockSignal = { + aborted: false, + addEventListener: (event, fn) => { + listeners.push({ event, fn }); + }, + removeEventListener: (event, fn) => { + const idx = listeners.findIndex((l) => l.event === event && l.fn === fn); + if (idx !== -1) listeners.splice(idx, 1); + }, + }; + + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + signal: mockSignal, + }); + strictEqual(listeners.length, 1); + + const closePromise = new Promise((resolve) => stream.once("close", resolve)); + stream.destroy(); + await closePromise; + + strictEqual(listeners.length, 0, "listener removed on destroy"); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: stop() correctly handles already-stopped idempotency +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: stop() when not stopped pushes null to end stream", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + stream.resume(); + + const endPromise = new Promise((resolve) => stream.once("end", resolve)); + await stream.stop(); + await endPromise; + // Second call is no-op + await stream.stop(); + strictEqual(consumer.stopCalled, 1); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: wantsMore backpressure - eachMessage completes when wantsMore=true +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: wantsMore=true means eachMessage does NOT call waitForRead", async () => { + // With large HWM, push() returns true (wantsMore), so waitForRead is NOT called + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream( + { consumer, topics: "t" }, + { highWaterMark: 100 }, + ); + + let delivered = false; + const delivery = (async () => { + await consumer._eachMessage({ + topic: "t", + partition: 0, + message: { + offset: "0", + key: null, + value: "v", + headers: {}, + timestamp: "0", + }, + }); + delivered = true; + })(); + + // Give a tick; eachMessage should complete without blocking (no waitForRead) + await new Promise((resolve) => setTimeout(resolve, 5)); + ok(delivered, "eachMessage completed without waiting for read"); + + await stream.stop(); + await delivery; + stream.destroy(); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: signal.aborted check prevents double-stop +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: non-aborted signal does NOT call stop immediately", async () => { + const consumer = makeMessageConsumer([]); + const ac = new AbortController(); + + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + signal: ac.signal, + }); + // Consumer.stop should NOT have been called yet (signal is not aborted) + strictEqual(consumer.stopCalled, 0, "stop not called for non-aborted signal"); + + await stream.stop(); + stream.destroy(); +}); + +test("kafkaConsumeStream: non-aborted signal registers abort listener (not stop immediately)", async () => { + const consumer = makeMessageConsumer([]); + const listeners = []; + const mockSignal = { + aborted: false, + addEventListener: (event, fn) => listeners.push({ event, fn }), + removeEventListener: (_event, fn) => { + const idx = listeners.findIndex((l) => l.fn === fn); + if (idx !== -1) listeners.splice(idx, 1); + }, + }; + + const stream = await kafkaConsumeStream({ + consumer, + topics: "t", + signal: mockSignal, + }); + // Should have added listener (not called stop) + strictEqual(consumer.stopCalled, 0); + strictEqual(listeners.length, 1); + strictEqual(listeners[0].event, "abort"); + await stream.stop(); + stream.destroy(); +}); + +// --------------------------------------------------------------------------- +// kafkaConnect: dynamic import fallback (Kafka not injected) +// --------------------------------------------------------------------------- + +test("kafkaConnect: uses dynamic import when Kafka is not provided (no connect)", async () => { + // Call without injecting Kafka — exercises the `Kafka ??= (await import('kafkajs')).Kafka` branch. + // Pass producer:false and omit groupId so neither producer nor consumer is + // created, meaning connect() is never called and no broker is needed. + const result = await kafkaConnect({ producer: false }); + strictEqual(result.producer, null, "producer is null"); + strictEqual(result.consumer, null, "consumer is null"); + ok(result.kafka, "kafka instance created via dynamic import"); + ok(typeof result.disconnect === "function"); + await result.disconnect(); +}); + +// --------------------------------------------------------------------------- +// kafkaConnect: disconnect() throws during error teardown (catch handlers) +// --------------------------------------------------------------------------- + +test("kafkaConnect: producer.disconnect() throwing in catch block is swallowed", async () => { + const { Kafka } = makeFakeKafka({ + connectProducer: async () => { + throw new Error("producer connect failed"); + }, + disconnectProducer: async () => { + throw new Error("producer disconnect also failed"); + }, + }); + // Should still rethrow the original connect error, not the disconnect error + await rejects(async () => kafkaConnect({ Kafka }), /producer connect failed/); +}); + +test("kafkaConnect: consumer.disconnect() throwing in catch block is swallowed", async () => { + const { Kafka } = makeFakeKafka({ + connectConsumer: async () => { + throw new Error("consumer connect failed"); + }, + disconnectConsumer: async () => { + throw new Error("consumer disconnect also failed"); + }, + }); + // Should rethrow the consumer connect error despite disconnect throwing + await rejects( + async () => kafkaConnect({ groupId: "g1", Kafka }), + /consumer connect failed/, + ); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: consumer.stop() throwing is swallowed by stopConsumerOnce +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: consumer.stop() throwing is swallowed (stopConsumerOnce catch)", async () => { + // Build a consumer whose stop() rejects — this exercises the .catch(() => {}) + // in stopConsumerOnce, which must not propagate the error. + const consumer = { + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stopCalled: 0, + _runResolve: null, + stop: async () => { + consumer.stopCalled++; + if (consumer._runResolve) consumer._runResolve(); + throw new Error("consumer.stop exploded"); + }, + run: async () => + new Promise((resolve) => { + consumer._runResolve = resolve; + }), + }; + + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + stream.resume(); + // stop() must not throw even though consumer.stop() does + await stream.stop(); + strictEqual(consumer.stopCalled, 1); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: runPromise rejection is swallowed (anonymous_22) +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: precise backpressure contract +// - when the buffer is FULL (push returns false / wantsMore=false) eachMessage +// MUST suspend on waitForRead() until the next _read releases it. +// - when the buffer has ROOM (push returns true / wantsMore=true) eachMessage +// MUST NOT suspend. +// These pin down `if (!wantsMore) await waitForRead()` and waitForRead()'s +// Promise so the BooleanLiteral / ConditionalExpression / ArrowFunction mutants +// all fail. +// --------------------------------------------------------------------------- + +const microtick = () => new Promise((resolve) => setImmediate(resolve)); + +test("kafkaConsumeStream: eachMessage SUSPENDS while buffer is full, resumes on read", async () => { + const consumer = makeMessageConsumer([]); + // HWM 1 => first push fills the buffer and returns false (wantsMore=false). + const stream = await kafkaConsumeStream( + { consumer, topics: "t" }, + { highWaterMark: 1 }, + ); + + let settled = false; + const p = consumer + ._eachMessage({ + topic: "t", + partition: 0, + message: { + offset: "0", + key: null, + value: "v1", + headers: {}, + timestamp: "0", + }, + }) + .then(() => { + settled = true; + }); + + // eachMessage must still be parked on waitForRead() — nothing has read yet. + await microtick(); + strictEqual( + settled, + false, + "eachMessage should be suspended while buffer full", + ); + + // Reading one item triggers _read -> releaseReaders -> resolves waitForRead. + const item = stream.read(); + strictEqual(item.value, "v1"); + await p; + strictEqual(settled, true, "eachMessage resumes after _read"); + + await stream.stop(); + stream.destroy(); +}); + +test("kafkaConsumeStream: eachMessage does NOT suspend while buffer has room", async () => { + const consumer = makeMessageConsumer([]); + // Large HWM => push returns true (wantsMore=true) => no waitForRead(). + const stream = await kafkaConsumeStream( + { consumer, topics: "t" }, + { highWaterMark: 100 }, + ); + + let settled = false; + const p = consumer + ._eachMessage({ + topic: "t", + partition: 0, + message: { + offset: "0", + key: null, + value: "v1", + headers: {}, + timestamp: "0", + }, + }) + .then(() => { + settled = true; + }); + + // Drain MICROTASKS only (no setImmediate / I/O turn). The stream's internal + // _read() — which would release a wrongly-parked waiter — is scheduled on a + // macrotask, so it has NOT run yet. With correct code (wantsMore=true => no + // waitForRead) eachMessage resolves purely via microtasks. With a mutant that + // always waits (`if (true)`) or inverts the guard, eachMessage is still parked + // here because only a macrotask _read could release it. + for (let i = 0; i < 20; i++) await Promise.resolve(); + strictEqual( + settled, + true, + "eachMessage should complete via microtasks (no waitForRead) when buffer has room", + ); + await p; + + await stream.stop(); + stream.destroy(); +}); + +// --------------------------------------------------------------------------- +// kafkaConsumeStream: stop() idempotency guard (if (stopped) return) prevents a +// second stream.push(null), which would emit ERR_STREAM_PUSH_AFTER_EOF. +// --------------------------------------------------------------------------- + +test("kafkaConsumeStream: second stop() does not push null again / emit an error", async () => { + const consumer = makeMessageConsumer([]); + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + stream.resume(); + + let errored = null; + stream.on("error", (e) => { + errored = e; + }); + + const endPromise = new Promise((resolve) => stream.once("end", resolve)); + await stream.stop(); + await endPromise; + // Second stop() must early-return; without the `if (stopped) return` guard it + // would call stream.push(null) again after EOF and emit an error event. + await stream.stop(); + await new Promise((resolve) => setTimeout(resolve, 10)); + strictEqual(errored, null, "no error from a redundant second stop()"); + strictEqual(consumer.stopCalled, 1); +}); + +test("kafkaConsumeStream: runPromise rejection is swallowed by stop()", async () => { + // Build a consumer whose run() promise rejects — exercises the + // runPromise.catch(() => {}) in stop(), which must swallow the error. + const consumer = { + connect: async () => {}, + disconnect: async () => {}, + subscribe: async () => {}, + stopCalled: 0, + _runResolve: null, + _runReject: null, + stop: async () => { + consumer.stopCalled++; + if (consumer._runReject) consumer._runReject(new Error("run rejected")); + }, + run: async () => + new Promise((_resolve, reject) => { + consumer._runReject = reject; + }), + }; + + const stream = await kafkaConsumeStream({ consumer, topics: "t" }); + stream.resume(); + // stop() must not throw even though runPromise rejects + await stream.stop(); + strictEqual(consumer.stopCalled, 1); +}); diff --git a/packages/kafka/index.tst.ts b/packages/kafka/index.tst.ts new file mode 100644 index 0000000..c19800c --- /dev/null +++ b/packages/kafka/index.tst.ts @@ -0,0 +1,71 @@ +/// +/// +import type { KafkaConnection } from "@datastream/kafka"; +import { + kafkaConnect, + kafkaConsumeStream, + kafkaProduceStream, +} from "@datastream/kafka"; +import { describe, expect, test } from "tstyche"; + +const producer: unknown = {}; +const consumer: unknown = {}; + +describe("kafkaConnect", () => { + test("accepts no args", () => { + expect(kafkaConnect()).type.not.toBeAssignableTo(); + }); + + test("accepts broker config", () => { + expect( + kafkaConnect({ + brokers: ["localhost:9092"], + clientId: "c", + groupId: "g", + }), + ).type.not.toBeAssignableTo(); + }); + + test("accepts producer: false", () => { + expect( + kafkaConnect({ producer: false }), + ).type.not.toBeAssignableTo(); + }); + + test("resolves a KafkaConnection", () => { + expect(kafkaConnect()).type.toBe>(); + }); +}); + +describe("kafkaProduceStream", () => { + test("requires producer and topic", () => { + expect( + kafkaProduceStream({ producer, topic: "t" }), + ).type.not.toBeAssignableTo(); + }); + + test("accepts batching options", () => { + expect( + kafkaProduceStream({ producer, topic: "t", batchSize: 50, acks: -1 }), + ).type.not.toBeAssignableTo(); + }); +}); + +describe("kafkaConsumeStream", () => { + test("requires consumer and topics", () => { + expect( + kafkaConsumeStream({ consumer, topics: "t" }), + ).type.not.toBeAssignableTo(); + }); + + test("accepts an array of topics and a signal", () => { + expect( + kafkaConsumeStream({ + consumer, + topics: ["a", "b"], + fromBeginning: true, + signal: new AbortController().signal, + }), + ).type.not.toBeAssignableTo(); + }); +}); diff --git a/packages/kafka/index.web.js b/packages/kafka/index.web.js new file mode 100644 index 0000000..2251365 --- /dev/null +++ b/packages/kafka/index.web.js @@ -0,0 +1,24 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT + +// @datastream/kafka wraps kafkajs, which speaks the Kafka wire protocol over raw +// TCP sockets — unavailable in the browser. There is no Web Streams equivalent, +// so the browser/`webstream` export resolves to these stubs: importing the +// package stays side-effect free (safe for bundlers and SSR), but calling any +// export fails loudly instead of silently returning from an empty module. Use +// this package from Node.js. +const unsupported = (name) => () => { + throw new Error( + `${name} is not supported in the browser: @datastream/kafka requires Node.js (kafkajs over TCP).`, + ); +}; + +export const kafkaConnect = unsupported("kafkaConnect"); +export const kafkaProduceStream = unsupported("kafkaProduceStream"); +export const kafkaConsumeStream = unsupported("kafkaConsumeStream"); + +export default { + connect: kafkaConnect, + produceStream: kafkaProduceStream, + consumeStream: kafkaConsumeStream, +}; diff --git a/packages/kafka/package.json b/packages/kafka/package.json new file mode 100644 index 0000000..88542d9 --- /dev/null +++ b/packages/kafka/package.json @@ -0,0 +1,81 @@ +{ + "name": "@datastream/kafka", + "version": "0.6.1", + "description": "Kafka producer and consumer streams (Node, via kafkajs)", + "type": "module", + "engines": { + "node": ">=24" + }, + "engineStrict": true, + "publishConfig": { + "access": "public" + }, + "main": "./index.node.mjs", + "module": "./index.web.mjs", + "exports": { + ".": { + "node": { + "import": { + "types": "./index.d.ts", + "default": "./index.node.mjs" + } + }, + "import": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "./webstream": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "types": "index.d.ts", + "files": [ + "*.mjs", + "*.map", + "*.d.ts" + ], + "scripts": { + "test": "npm run test:unit", + "test:unit": "node --test", + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" + }, + "license": "MIT", + "keywords": [ + "kafka", + "kafkajs", + "msk", + "streaming", + "Web Stream API", + "Node Stream API" + ], + "author": { + "name": "datastream contributors", + "url": "https://github.com/willfarrell/datastream/graphs/contributors" + }, + "repository": { + "type": "git", + "url": "github:willfarrell/datastream", + "directory": "packages/kafka" + }, + "bugs": { + "url": "https://github.com/willfarrell/datastream/issues" + }, + "homepage": "https://datastream.js.org", + "dependencies": { + "@datastream/core": "0.6.1" + }, + "peerDependencies": { + "kafkajs": "^2.0.0" + }, + "peerDependenciesMeta": { + "kafkajs": { + "optional": true + } + }, + "devDependencies": { + "kafkajs": "^2.0.0" + } +} diff --git a/packages/object/README.md b/packages/object/README.md index 915d58f..0b41113 100644 --- a/packages/object/README.md +++ b/packages/object/README.md @@ -1,6 +1,6 @@

<datastream> `object`

- datastream logo + datastream logo

Object manipulation streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/object/index.js b/packages/object/index.js index 61cec69..17b43fc 100644 --- a/packages/object/index.js +++ b/packages/object/index.js @@ -31,7 +31,7 @@ export const objectBatchStream = ( let previousId; let batch; const transform = (chunk, enqueue) => { - const id = keys.map((key) => chunk[key]).join(" "); + const id = JSON.stringify(keys.map((key) => chunk[key])); if (previousId !== id) { if (batch) { enqueue(batch); @@ -163,7 +163,9 @@ export const objectKeyMapStream = ({ keys }, streamOptions = {}) => { export const objectValueMapStream = ({ key, values }, streamOptions = {}) => { const transform = (chunk, enqueue) => { - chunk[key] = values[chunk[key]]; + if (Object.hasOwn(values, chunk[key])) { + chunk[key] = values[chunk[key]]; + } enqueue(chunk); }; return createTransformStream(transform, streamOptions); diff --git a/packages/object/index.test.js b/packages/object/index.test.js index 6c0a741..15b2982 100644 --- a/packages/object/index.test.js +++ b/packages/object/index.test.js @@ -7,7 +7,7 @@ import { streamToArray, } from "@datastream/core"; -import { +import objectDefault, { objectBatchStream, objectCountStream, objectFromEntriesStream, @@ -519,6 +519,46 @@ test(`${variant}: objectPivotWideToLongStream should not mutate input chunks`, a deepStrictEqual(input[0], original); }); +// *** objectPivotWideToLongStream isNestedObject deep clone *** // +test(`${variant}: objectPivotWideToLongStream should deep clone when isNestedObject is true`, async (_t) => { + const input = [{ id: 1, a: { x: 10 }, b: { x: 20 } }]; + const streams = [ + createReadableStream(input), + objectPivotWideToLongStream({ + keys: ["a", "b"], + keyParam: "axis", + valueParam: "val", + isNestedObject: true, + }), + ]; + + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + { id: 1, axis: "a", val: { x: 10 } }, + { id: 1, axis: "b", val: { x: 20 } }, + ]); +}); + +// *** objectKeyJoinStream isNestedObject deep clone *** // +test(`${variant}: objectKeyJoinStream should deep clone when isNestedObject is true`, async (_t) => { + const input = [{ firstName: "John", lastName: "Doe", nested: { v: 1 } }]; + const streams = [ + createReadableStream(input), + objectKeyJoinStream({ + keys: { fullName: ["firstName", "lastName"] }, + separator: " ", + isNestedObject: true, + }), + ]; + + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ nested: { v: 1 }, fullName: "John Doe" }]); +}); + // *** objectKeyJoinStream shallow copy regression *** // test(`${variant}: objectKeyJoinStream should not mutate input chunks`, async (_t) => { const input = [{ first: "a", last: "b", other: "c" }]; @@ -557,6 +597,40 @@ test(`${variant}: objectBatchStream should enforce maxBatchSize`, async (_t) => ); }); +// *** objectBatchStream collision-proof grouping key *** // +test(`${variant}: objectBatchStream should keep distinct key tuples in separate batches when values contain spaces`, async (_t) => { + // ["a b","c"] and ["a","b c"] both join to "a b c" with space-delimiter — + // they must produce two separate batches, not one merged batch. + const input = [ + { x: "a b", y: "c" }, + { x: "a", y: "b c" }, + ]; + const streams = [ + createReadableStream(input), + objectBatchStream({ keys: ["x", "y"] }), + ]; + + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [[{ x: "a b", y: "c" }], [{ x: "a", y: "b c" }]]); +}); + +// *** objectValueMapStream preserves unmapped keys *** // +test(`${variant}: objectValueMapStream should preserve the original value for keys not present in the map`, async (_t) => { + const input = [{ status: "active" }, { status: "unknown" }]; + const streams = [ + createReadableStream(input), + objectValueMapStream({ key: "status", values: { active: 1, inactive: 0 } }), + ]; + + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + // "unknown" is not in the map — original value must be preserved + deepStrictEqual(output, [{ status: 1 }, { status: "unknown" }]); +}); + // *** objectPivotLongToWideStream should not mutate input *** // test(`${variant}: objectPivotLongToWideStream should not mutate input chunks`, async (_t) => { const input = [ @@ -579,3 +653,52 @@ test(`${variant}: objectPivotLongToWideStream should not mutate input chunks`, a // Original input[0][0] should be unchanged deepStrictEqual(input[0][0], originalFirst); }); + +// *** objectReadableStream default empty input *** // +test(`${variant}: objectReadableStream should produce empty output when called with no args`, async (_t) => { + const streams = [objectReadableStream()]; + const output = await streamToArray(streams[0]); + deepStrictEqual(output, []); +}); + +// *** objectBatchStream should produce no output on empty input *** // +test(`${variant}: objectBatchStream should produce no output when input is empty`, async (_t) => { + const streams = [ + createReadableStream([]), + objectBatchStream({ keys: ["a"] }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, []); +}); + +// *** objectToEntriesStream should produce empty arrays when keys is empty *** // +test(`${variant}: objectToEntriesStream should produce empty array when keys is empty`, async (_t) => { + const input = [{ a: 1, b: 2 }]; + const streams = [ + createReadableStream(input), + objectToEntriesStream({ keys: [] }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [[]]); +}); + +// *** default export contains all stream factories *** // +test(`${variant}: default export should contain all stream factories`, async (_t) => { + strictEqual(typeof objectDefault.readableStream, "function"); + strictEqual(typeof objectDefault.countStream, "function"); + strictEqual(typeof objectDefault.pickStream, "function"); + strictEqual(typeof objectDefault.omitStream, "function"); + strictEqual(typeof objectDefault.batchStream, "function"); + strictEqual(typeof objectDefault.pivotLongToWideStream, "function"); + strictEqual(typeof objectDefault.pivotWideToLongStream, "function"); + strictEqual(typeof objectDefault.keyValueStream, "function"); + strictEqual(typeof objectDefault.keyValuesStream, "function"); + strictEqual(typeof objectDefault.keyJoinStream, "function"); + strictEqual(typeof objectDefault.keyMapStream, "function"); + strictEqual(typeof objectDefault.valueMapStream, "function"); + strictEqual(typeof objectDefault.fromEntriesStream, "function"); + strictEqual(typeof objectDefault.toEntriesStream, "function"); + strictEqual(typeof objectDefault.skipConsecutiveDuplicatesStream, "function"); +}); diff --git a/packages/object/package.json b/packages/object/package.json index c922759..46dbd3a 100644 --- a/packages/object/package.json +++ b/packages/object/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/object", - "version": "0.5.0", + "version": "0.6.1", "description": "Object transform streams for picking, omitting, pivoting, batching, and key mapping", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,6 +61,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/protobuf/README.md b/packages/protobuf/README.md new file mode 100644 index 0000000..32a67dc --- /dev/null +++ b/packages/protobuf/README.md @@ -0,0 +1,52 @@ +
+

<datastream> `protobuf`

+ datastream logo +

Protobuf encode/decode and length-prefix framing streams.

+

+ GitHub Actions unit test status + GitHub Actions dast test status + GitHub Actions perf test status + GitHub Actions SAST test status + GitHub Actions lint test status +
+ npm version + npm install size + + npm weekly downloads + + npm provenance +
+ Open Source Security Foundation (OpenSSF) Scorecard + SLSA 3 + + Checked with Biome + Conventional Commits + + code coverage +

+

You can read the documentation at: https://datastream.js.org

+
+ + +## Install + +To install datastream you can use NPM: + +```bash +npm install --save @datastream/protobuf +``` + + +## Documentation and examples + +For documentation and examples, refer to the main [datastream monorepo on GitHub](https://github.com/willfarrell/datastream) or [datastream official website](https://datastream.js.org). + + +## Contributing + +Everyone is very welcome to contribute to this repository. Feel free to [raise issues](https://github.com/willfarrell/datastream/issues) or to [submit Pull Requests](https://github.com/willfarrell/datastream/pulls). + + +## License + +Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell), and [datastream contributors](https://github.com/willfarrell/datastream/graphs/contributors). \ No newline at end of file diff --git a/packages/protobuf/_old.js b/packages/protobuf/_old.js new file mode 100644 index 0000000..e69de29 diff --git a/packages/protobuf/index.d.ts b/packages/protobuf/index.d.ts new file mode 100644 index 0000000..057fac4 --- /dev/null +++ b/packages/protobuf/index.d.ts @@ -0,0 +1,50 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import type { DatastreamTransform, StreamOptions } from "@datastream/core"; + +export interface ProtobufType { + encode(message: unknown): { finish(): Uint8Array }; + decode(buffer: Uint8Array): unknown; + create(message: unknown): unknown; +} + +// Type can be: +// - a static ProtobufType value +// - a sync function that takes the current chunk and returns a Type +// - an async function returning a Promise +// When a static value is passed, it's cached so the hot path doesn't recompute. +export type ProtobufTypeInput = + | ProtobufType + | ((chunk: unknown) => ProtobufType | Promise); + +export function protobufEncodeStream( + options: { Type: ProtobufTypeInput }, + streamOptions?: StreamOptions, +): DatastreamTransform; + +export function protobufDecodeStream( + options: { + Type: ProtobufTypeInput; + /** Extract the protobuf payload bytes from each chunk. Default: identity. */ + payload?: (chunk: C) => Uint8Array; + /** + * Caps the CUMULATIVE encoded INPUT bytes processed across the whole + * stream. This is a coarse input-volume guard, NOT a bound on decoded + * output memory: protobuf can expand a small encoded message into a much + * larger in-memory object graph (packed/repeated/nested fields), so the + * decoded objects may exceed this value. + */ + maxOutputSize?: number; + }, + streamOptions?: StreamOptions, +): DatastreamTransform; + +export function protobufLengthPrefixFrameStream( + options?: Record, + streamOptions?: StreamOptions, +): DatastreamTransform; + +export function protobufLengthPrefixUnframeStream( + options?: { maxMessageSize?: number }, + streamOptions?: StreamOptions, +): DatastreamTransform; diff --git a/packages/protobuf/index.fuzz.js b/packages/protobuf/index.fuzz.js new file mode 100644 index 0000000..e2b10b9 --- /dev/null +++ b/packages/protobuf/index.fuzz.js @@ -0,0 +1,234 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import test from "node:test"; +import { + createReadableStream, + pipejoin, + pipeline, + streamToArray, +} from "@datastream/core"; +import { + protobufDecodeStream, + protobufEncodeStream, + protobufLengthPrefixFrameStream, + protobufLengthPrefixUnframeStream, +} from "@datastream/protobuf"; +import fc from "fast-check"; +import protobuf from "protobufjs"; + +const Type = new protobuf.Type("Msg") + .add(new protobuf.Field("id", 1, "int32")) + .add(new protobuf.Field("name", 2, "string")); + +const catchError = (input, e) => { + if ( + e.message.includes("maxMessageSize") || + e.message.includes("maxOutputSize") || + e.message.includes("incomplete message") || + /invalid wire type/i.test(e.message) || + /invalid tag/i.test(e.message) || + /invalid varint/i.test(e.message) || + /invalid end group/i.test(e.message) || + /index out of range/i.test(e.message) || + /max depth exceeded/i.test(e.message) || + /illegal/i.test(e.message) + ) { + return; + } + console.error(input, e); + throw e; +}; + +// *** protobufEncodeStream + protobufDecodeStream round-trip *** // +test("fuzz encode→decode round-trip with random {id, name}", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + id: fc.integer({ min: -2147483648, max: 2147483647 }), + name: fc.string(), + }), + { minLength: 1 }, + ), + async (input) => { + try { + const encoded = await streamToArray( + pipejoin([ + createReadableStream(input), + protobufEncodeStream({ Type }), + ]), + ); + const decoded = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufDecodeStream({ Type }), + ]), + ); + for (let i = 0; i < input.length; i++) { + const got = decoded[i]; + const want = input[i]; + if (got.id !== want.id || got.name !== want.name) { + throw new Error( + `Round-trip mismatch at ${i}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`, + ); + } + } + } catch (e) { + catchError(input, e); + } + }, + ), + { numRuns: 1_000, verbose: 2, examples: [] }, + ); +}); + +// *** protobufDecodeStream with random bytes *** // +test("fuzz protobufDecodeStream with random byte inputs", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.uint8Array({ minLength: 0, maxLength: 64 }), { + minLength: 1, + }), + async (chunks) => { + try { + await pipeline([ + createReadableStream(chunks), + protobufDecodeStream({ Type }), + ]); + } catch (e) { + catchError(chunks, e); + } + }, + ), + { numRuns: 1_000, verbose: 2, examples: [] }, + ); +}); + +// *** protobufDecodeStream maxOutputSize guard *** // +test("fuzz protobufDecodeStream with maxOutputSize", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + id: fc.integer({ min: 0, max: 100 }), + name: fc.string({ maxLength: 32 }), + }), + { minLength: 1, maxLength: 10 }, + ), + fc.integer({ min: 1, max: 512 }), + async (input, maxOutputSize) => { + try { + const encoded = await streamToArray( + pipejoin([ + createReadableStream(input), + protobufEncodeStream({ Type }), + ]), + ); + await pipeline([ + createReadableStream(encoded), + protobufDecodeStream({ Type, maxOutputSize }), + ]); + } catch (e) { + catchError(input, e); + } + }, + ), + { numRuns: 1_000, verbose: 2, examples: [] }, + ); +}); + +// *** protobufLengthPrefixUnframeStream with random byte chunks *** // +test("fuzz protobufLengthPrefixUnframeStream with random byte chunks", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.uint8Array({ minLength: 0, maxLength: 64 }), { + minLength: 1, + }), + async (chunks) => { + try { + await pipeline([ + createReadableStream(chunks), + protobufLengthPrefixUnframeStream(), + ]); + } catch (e) { + catchError(chunks, e); + } + }, + ), + { numRuns: 1_000, verbose: 2, examples: [] }, + ); +}); + +// *** protobufLengthPrefixUnframeStream maxMessageSize guard *** // +test("fuzz protobufLengthPrefixUnframeStream with maxMessageSize", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.uint8Array({ minLength: 1, maxLength: 64 }), { + minLength: 1, + maxLength: 8, + }), + fc.integer({ min: 1, max: 128 }), + async (chunks, maxMessageSize) => { + try { + await pipeline([ + createReadableStream(chunks), + protobufLengthPrefixUnframeStream({ maxMessageSize }), + ]); + } catch (e) { + catchError(chunks, e); + } + }, + ), + { numRuns: 1_000, verbose: 2, examples: [] }, + ); +}); + +// *** frame→unframe round-trip with random payloads *** // +test("fuzz frame→unframe round-trip with random payloads", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.uint8Array({ minLength: 0, maxLength: 128 }), { + minLength: 1, + maxLength: 16, + }), + async (payloads) => { + try { + const framed = await streamToArray( + pipejoin([ + createReadableStream(payloads), + protobufLengthPrefixFrameStream(), + ]), + ); + const unframed = await streamToArray( + pipejoin([ + createReadableStream(framed), + protobufLengthPrefixUnframeStream(), + ]), + ); + if (unframed.length !== payloads.length) { + throw new Error( + `Length mismatch: got ${unframed.length}, want ${payloads.length}`, + ); + } + for (let i = 0; i < payloads.length; i++) { + const got = unframed[i]; + const want = payloads[i]; + if (got.length !== want.length) { + throw new Error( + `Payload length mismatch at ${i}: got ${got.length}, want ${want.length}`, + ); + } + for (let j = 0; j < want.length; j++) { + if (got[j] !== want[j]) { + throw new Error(`Byte mismatch at ${i}[${j}]`); + } + } + } + } catch (e) { + catchError(payloads, e); + } + }, + ), + { numRuns: 1_000, verbose: 2, examples: [] }, + ); +}); diff --git a/packages/protobuf/index.js b/packages/protobuf/index.js new file mode 100644 index 0000000..963c77b --- /dev/null +++ b/packages/protobuf/index.js @@ -0,0 +1,184 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { createTransformStream } from "@datastream/core"; + +// Resolve a ProtobufTypeInput once: a static Type is returned as-is (the hot +// path skips recomputation), while a function is invoked per chunk and may +// return either a Type or a Promise. +const makeResolveType = (Type) => { + if (typeof Type === "function") { + return (chunk) => Type(chunk); + } + return () => Type; +}; + +export const protobufEncodeStream = ({ Type } = {}, streamOptions = {}) => { + const resolveType = makeResolveType(Type); + const transform = async (chunk, enqueue) => { + const type = await resolveType(chunk); + enqueue(type.encode(type.create(chunk)).finish()); + }; + return createTransformStream(transform, streamOptions); +}; + +export const protobufDecodeStream = ( + { Type, payload, maxOutputSize = Number.POSITIVE_INFINITY } = {}, + streamOptions = {}, +) => { + const resolveType = makeResolveType(Type); + const getPayload = payload ?? ((chunk) => chunk); + let inputSize = 0; + const transform = async (chunk, enqueue) => { + const bytes = getPayload(chunk); + // Default ceiling is Infinity, so the guard is the sole gate and the + // comparison stays load-bearing (no redundant null check to mutate away). + inputSize += bytes.length; + if (inputSize > maxOutputSize) { + throw new Error( + `Protobuf decode input exceeds maxOutputSize (${maxOutputSize} bytes)`, + ); + } + const type = await resolveType(chunk); + enqueue(type.decode(bytes)); + }; + return createTransformStream(transform, streamOptions); +}; + +// Base-128 varint, matching the protobuf length-delimited wire format. Uses +// arithmetic (not bitwise) so lengths beyond 2^31 encode/decode correctly. +const encodeVarint = (value) => { + const bytes = []; + let v = value; + while (v > 0x7f) { + bytes.push((v % 0x80) | 0x80); + v = Math.floor(v / 0x80); + } + bytes.push(v); + return Uint8Array.from(bytes); +}; + +export const protobufLengthPrefixFrameStream = ( + _options = {}, + streamOptions = {}, +) => { + const transform = (chunk, enqueue) => { + const prefix = encodeVarint(chunk.length); + const framed = new Uint8Array(prefix.length + chunk.length); + framed.set(prefix, 0); + framed.set(chunk, prefix.length); + enqueue(framed); + }; + return createTransformStream(transform, streamOptions); +}; + +export const protobufLengthPrefixUnframeStream = ( + { maxMessageSize = Number.POSITIVE_INFINITY } = {}, + streamOptions = {}, +) => { + // Buffer incoming chunks as a list instead of one growable byte buffer: the + // varint length prefix is parsed across chunk boundaries and each message is + // copied out exactly once. This keeps the hot path free of per-chunk realloc + // and — just as importantly — free of capacity-reuse branches whose only effect + // is speed (and which mutation testing cannot distinguish from the slow path). + const pending = []; + let pendingLen = 0; + + // Byte at absolute position `pos` (0 <= pos < pendingLen) across the chunks. + const byteAt = (pos) => { + let index = 0; + while (pos >= pending[index].byteLength) { + pos -= pending[index].byteLength; + index += 1; + } + return pending[index][pos]; + }; + + // Drop the first `count` bytes off the front of the buffered chunks. + const skip = (count) => { + while (count > 0) { + const head = pending[0]; + const n = Math.min(head.byteLength, count); + count -= n; + pendingLen -= n; + const rest = head.subarray(n); + if (rest.byteLength === 0) { + pending.shift(); + } else { + pending[0] = rest; + } + } + }; + + // Copy the first `count` bytes off the front of the buffered chunks into a new + // contiguous Uint8Array, consuming them. + const take = (count) => { + const out = new Uint8Array(count); + let filled = 0; + while (filled < count) { + const head = pending[0]; + const n = Math.min(head.byteLength, count - filled); + out.set(head.subarray(0, n), filled); + filled += n; + const rest = head.subarray(n); + if (rest.byteLength === 0) { + pending.shift(); + } else { + pending[0] = rest; + } + } + pendingLen -= count; + return out; + }; + + // Pull one complete length-prefixed message off the front, or return null when + // the buffered bytes don't yet hold a full prefix + body. + const readMessage = () => { + let length = 0; + let scale = 1; + let prefixBytes = 0; + let complete = false; + while (prefixBytes < pendingLen) { + const byte = byteAt(prefixBytes); + length += (byte & 0x7f) * scale; + prefixBytes += 1; + if ((byte & 0x80) === 0) { + complete = true; + break; + } + scale *= 0x80; + } + if (!complete) { + return null; + } + if (length > maxMessageSize) { + throw new Error( + `Protobuf message exceeds maxMessageSize (${maxMessageSize} bytes)`, + ); + } + if (pendingLen - prefixBytes < length) { + return null; + } + skip(prefixBytes); + return take(length); + }; + + const transform = (chunk, enqueue) => { + pending.push(chunk); + pendingLen += chunk.byteLength; + let message = readMessage(); + while (message !== null) { + enqueue(message); + message = readMessage(); + } + }; + + const flush = () => { + if (pendingLen > 0) { + throw new Error( + "Protobuf unframe stream ended with an incomplete message", + ); + } + }; + + return createTransformStream(transform, flush, streamOptions); +}; diff --git a/packages/protobuf/index.perf.js b/packages/protobuf/index.perf.js new file mode 100644 index 0000000..ec92f39 --- /dev/null +++ b/packages/protobuf/index.perf.js @@ -0,0 +1,124 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import test from "node:test"; +import { + createReadableStream, + pipejoin, + streamToArray, +} from "@datastream/core"; +import { + protobufDecodeStream, + protobufEncodeStream, + protobufLengthPrefixFrameStream, + protobufLengthPrefixUnframeStream, +} from "@datastream/protobuf"; +import protobuf from "protobufjs"; +import { Bench } from "tinybench"; + +// -- Data generators -- + +const time = Number(process.env.BENCH_TIME ?? 5_000); +const ITEMS = 10_000; + +const Type = new protobuf.Type("Msg") + .add(new protobuf.Field("id", 1, "int32")) + .add(new protobuf.Field("name", 2, "string")) + .add(new protobuf.Field("value", 3, "double")); + +const objects = Array.from({ length: ITEMS }, (_, i) => ({ + id: i, + name: `item_${i}`, + value: i * 1.5, +})); + +// Pre-encoded buffers for the decode/unframe benchmarks. +const encoded = await streamToArray( + pipejoin([createReadableStream(objects), protobufEncodeStream({ Type })]), +); +const framed = await streamToArray( + pipejoin([createReadableStream(encoded), protobufLengthPrefixFrameStream()]), +); + +// -- Tests -- + +test("perf: protobufEncodeStream", async () => { + const bench = new Bench({ name: "protobufEncodeStream", time }); + + bench.add(`${ITEMS} objects`, async () => { + const streams = [ + createReadableStream(objects), + protobufEncodeStream({ Type }), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: protobufDecodeStream", async () => { + const bench = new Bench({ name: "protobufDecodeStream", time }); + + bench.add(`${ITEMS} messages`, async () => { + const streams = [ + createReadableStream(encoded), + protobufDecodeStream({ Type }), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: protobuf roundtrip (encode → decode)", async () => { + const bench = new Bench({ name: "protobuf roundtrip", time }); + + bench.add(`${ITEMS} objects`, async () => { + const streams = [ + createReadableStream(objects), + protobufEncodeStream({ Type }), + protobufDecodeStream({ Type }), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: protobuf length-prefix framing roundtrip", async () => { + const bench = new Bench({ name: "protobuf framing roundtrip", time }); + + bench.add(`${ITEMS} messages`, async () => { + const streams = [ + createReadableStream(encoded), + protobufLengthPrefixFrameStream(), + protobufLengthPrefixUnframeStream(), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: protobufLengthPrefixUnframeStream", async () => { + const bench = new Bench({ name: "protobufLengthPrefixUnframeStream", time }); + + bench.add(`${ITEMS} framed messages`, async () => { + const streams = [ + createReadableStream(framed), + protobufLengthPrefixUnframeStream(), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); diff --git a/packages/protobuf/index.test.js b/packages/protobuf/index.test.js new file mode 100644 index 0000000..c3d179b --- /dev/null +++ b/packages/protobuf/index.test.js @@ -0,0 +1,348 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import test from "node:test"; +import { + createReadableStream, + pipejoin, + pipeline, + streamToArray, +} from "@datastream/core"; +import { + protobufDecodeStream, + protobufEncodeStream, + protobufLengthPrefixFrameStream, + protobufLengthPrefixUnframeStream, +} from "@datastream/protobuf"; +import protobuf from "protobufjs"; + +let variant = "unknown"; +for (const execArgv of process.execArgv) { + const flag = "--conditions="; + if (execArgv.includes("--conditions=")) { + variant = execArgv.replace(flag, ""); + } +} + +const Type = new protobuf.Type("Msg") + .add(new protobuf.Field("id", 1, "int32")) + .add(new protobuf.Field("name", 2, "string")); + +const messages = [ + { id: 1, name: "alpha" }, + { id: 2, name: "beta" }, + { id: 3, name: "gamma" }, +]; + +// A message whose encoded form is >127 bytes, forcing a multi-byte varint +// length prefix (exercises the encodeVarint loop and split-prefix framing). +const bigMessage = { id: 42, name: "x".repeat(200) }; + +const toPlain = (m) => ({ id: m.id, name: m.name }); + +const encodeAll = (input, options) => + streamToArray( + pipejoin([createReadableStream(input), protobufEncodeStream(options)]), + ); + +const concat = (chunks) => { + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +}; + +// *** protobufEncodeStream *** // +test(`${variant}: protobufEncodeStream encodes objects to bytes (static Type)`, async () => { + const encoded = await encodeAll(messages, { Type }); + strictEqual(encoded.length, 3); + for (const bytes of encoded) { + strictEqual(bytes instanceof Uint8Array, true); + } + // Round-trips back to the originals. + deepStrictEqual( + encoded.map((b) => toPlain(Type.decode(b))), + messages, + ); +}); + +test(`${variant}: protobufEncodeStream accepts a sync function Type`, async () => { + const encoded = await encodeAll(messages, { Type: () => Type }); + deepStrictEqual( + encoded.map((b) => toPlain(Type.decode(b))), + messages, + ); +}); + +test(`${variant}: protobufEncodeStream accepts an async function Type`, async () => { + const encoded = await encodeAll(messages, { Type: async () => Type }); + deepStrictEqual( + encoded.map((b) => toPlain(Type.decode(b))), + messages, + ); +}); + +test(`${variant}: protobufEncodeStream honors streamOptions`, async () => { + const encoded = await encodeAll(messages, { Type }, {}); + const stream = protobufEncodeStream({ Type }, { highWaterMark: 1 }); + strictEqual(typeof stream.pipe, "function"); + strictEqual(encoded.length, 3); +}); + +test(`${variant}: protobufEncodeStream constructs with no arguments`, () => { + const stream = protobufEncodeStream(); + strictEqual(typeof stream.pipe, "function"); +}); + +// *** protobufDecodeStream *** // +test(`${variant}: protobufDecodeStream decodes bytes to objects`, async () => { + const encoded = await encodeAll(messages, { Type }); + const decoded = await streamToArray( + pipejoin([createReadableStream(encoded), protobufDecodeStream({ Type })]), + ); + deepStrictEqual(decoded.map(toPlain), messages); +}); + +test(`${variant}: protobufDecodeStream extracts bytes via payload`, async () => { + const encoded = await encodeAll(messages, { Type }); + const wrapped = encoded.map((data) => ({ data })); + const decoded = await streamToArray( + pipejoin([ + createReadableStream(wrapped), + protobufDecodeStream({ Type, payload: (chunk) => chunk.data }), + ]), + ); + deepStrictEqual(decoded.map(toPlain), messages); +}); + +test(`${variant}: protobufDecodeStream allows input within maxOutputSize`, async () => { + const encoded = await encodeAll(messages, { Type }); + const decoded = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufDecodeStream({ Type, maxOutputSize: 1024 }), + ]), + ); + deepStrictEqual(decoded.map(toPlain), messages); +}); + +test(`${variant}: protobufDecodeStream allows input exactly at maxOutputSize`, async () => { + const encoded = await encodeAll(messages, { Type }); + // Cumulative input size equal to the ceiling must be accepted (boundary is + // strictly-greater, not greater-or-equal). + const exact = encoded.reduce((sum, b) => sum + b.length, 0); + const decoded = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufDecodeStream({ Type, maxOutputSize: exact }), + ]), + ); + deepStrictEqual(decoded.map(toPlain), messages); +}); + +test(`${variant}: protobufDecodeStream throws when input exceeds maxOutputSize`, async () => { + const encoded = await encodeAll(messages, { Type }); + try { + await pipeline([ + createReadableStream(encoded), + protobufDecodeStream({ Type, maxOutputSize: 1 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } +}); + +test(`${variant}: protobufDecodeStream constructs with no arguments`, () => { + const stream = protobufDecodeStream(); + strictEqual(typeof stream.pipe, "function"); +}); + +// *** length-prefix framing *** // +test(`${variant}: frame then unframe round-trips messages (one chunk each)`, async () => { + const encoded = await encodeAll(messages, { Type }); + const frames = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufLengthPrefixFrameStream(), + ]), + ); + strictEqual(frames.length, 3); + const unframed = await streamToArray( + pipejoin([ + createReadableStream(frames), + protobufLengthPrefixUnframeStream(), + ]), + ); + deepStrictEqual( + unframed.map((b) => toPlain(Type.decode(b))), + messages, + ); +}); + +test(`${variant}: frame uses a single-byte varint prefix for a 127-byte message`, async () => { + // 127 (0x7f) is the largest value that fits in a single varint byte; the + // encodeVarint loop must stop at v > 0x7f (a >= boundary would spill into a + // second 0x80-continuation byte). + const body = new Uint8Array(127).fill(7); + const frames = await streamToArray( + pipejoin([createReadableStream([body]), protobufLengthPrefixFrameStream()]), + ); + strictEqual(frames.length, 1); + strictEqual(frames[0].length, 128); // prefix(1) + body(127) + const unframed = await streamToArray( + pipejoin([ + createReadableStream(frames), + protobufLengthPrefixUnframeStream(), + ]), + ); + deepStrictEqual(unframed, [body]); +}); + +test(`${variant}: unframe allows a message exactly at maxMessageSize`, async () => { + // A message whose length equals the ceiling must pass (boundary is + // strictly-greater). + const body = new Uint8Array(50).fill(3); + const frames = await streamToArray( + pipejoin([createReadableStream([body]), protobufLengthPrefixFrameStream()]), + ); + const unframed = await streamToArray( + pipejoin([ + createReadableStream(frames), + protobufLengthPrefixUnframeStream({ maxMessageSize: 50 }), + ]), + ); + deepStrictEqual(unframed, [body]); +}); + +test(`${variant}: unframe reassembles messages split across byte-sized chunks`, async () => { + const encoded = await encodeAll([bigMessage, ...messages], { Type }); + const frames = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufLengthPrefixFrameStream(), + ]), + ); + // One contiguous buffer, then feed it one byte at a time so that both the + // multi-byte length prefix and the message body straddle chunk boundaries. + const stream = concat(frames); + const byteChunks = Array.from(stream, (b) => Uint8Array.of(b)); + const unframed = await streamToArray( + pipejoin([ + createReadableStream(byteChunks), + protobufLengthPrefixUnframeStream({ maxMessageSize: 4096 }), + ]), + ); + deepStrictEqual( + unframed.map((b) => toPlain(Type.decode(b))), + [bigMessage, ...messages], + ); +}); + +test(`${variant}: unframe reassembles a body split mid-chunk with trailing frames`, async () => { + // Two chunks where the split falls inside bigMessage's body, so a single + // message is assembled from a partial first chunk plus a second chunk that + // also carries the following frames. This drives the multi-chunk copy path: + // the body spans two buffered chunks and the second chunk's tail (the next + // frames) must be retained, not discarded. + const encoded = await encodeAll([bigMessage, ...messages], { Type }); + const frames = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufLengthPrefixFrameStream(), + ]), + ); + const stream = concat(frames); + // 100 lands inside bigMessage's body (2-byte prefix + ~200-byte body), and the + // remainder carries the rest of that body plus all three trailing frames. + const splitAt = 100; + const chunks = [stream.subarray(0, splitAt), stream.subarray(splitAt)]; + const unframed = await streamToArray( + pipejoin([ + createReadableStream(chunks), + protobufLengthPrefixUnframeStream({ maxMessageSize: 4096 }), + ]), + ); + deepStrictEqual( + unframed.map((b) => toPlain(Type.decode(b))), + [bigMessage, ...messages], + ); +}); + +test(`${variant}: unframe reassembles many small messages fed one byte at a time`, async () => { + // Many small messages, fed one byte at a time, so every prefix and body + // straddles chunk boundaries and the buffered-chunk list is repeatedly drained + // down to a partial message and refilled. + const small = Array.from({ length: 20 }, (_, i) => ({ + id: i, + name: `m${i}`, + })); + const encoded = await encodeAll(small, { Type }); + const frames = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufLengthPrefixFrameStream(), + ]), + ); + const byteChunks = Array.from(concat(frames), (b) => Uint8Array.of(b)); + const unframed = await streamToArray( + pipejoin([ + createReadableStream(byteChunks), + protobufLengthPrefixUnframeStream({ maxMessageSize: 4096 }), + ]), + ); + deepStrictEqual( + unframed.map((b) => toPlain(Type.decode(b))), + small, + ); +}); + +test(`${variant}: unframe throws when a message exceeds maxMessageSize`, async () => { + const encoded = await encodeAll([bigMessage], { Type }); + const frames = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufLengthPrefixFrameStream(), + ]), + ); + try { + await pipeline([ + createReadableStream(frames), + protobufLengthPrefixUnframeStream({ maxMessageSize: 8 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxMessageSize")); + } +}); + +test(`${variant}: unframe throws when the stream ends mid-message`, async () => { + // Prefix declares 5 bytes but only 2 follow. + const truncated = Uint8Array.of(5, 1, 2); + try { + await pipeline([ + createReadableStream([truncated]), + protobufLengthPrefixUnframeStream(), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("incomplete message")); + } +}); + +test(`${variant}: frame honors streamOptions and constructs with no arguments`, async () => { + const stream = protobufLengthPrefixFrameStream(undefined, {}); + strictEqual(typeof stream.pipe, "function"); + const encoded = await encodeAll(messages, { Type }); + const frames = await streamToArray( + pipejoin([ + createReadableStream(encoded), + protobufLengthPrefixFrameStream({}, {}), + ]), + ); + strictEqual(frames.length, 3); +}); diff --git a/packages/protobuf/index.tst.ts b/packages/protobuf/index.tst.ts new file mode 100644 index 0000000..4d3a1df --- /dev/null +++ b/packages/protobuf/index.tst.ts @@ -0,0 +1,66 @@ +/// +/// +import type { ProtobufType, ProtobufTypeInput } from "@datastream/protobuf"; +import { + protobufDecodeStream, + protobufEncodeStream, + protobufLengthPrefixFrameStream, + protobufLengthPrefixUnframeStream, +} from "@datastream/protobuf"; +import { describe, expect, test } from "tstyche"; + +const Type: ProtobufType = { + encode: () => ({ finish: () => new Uint8Array() }), + decode: () => ({}), + create: () => ({}), +}; + +describe("ProtobufTypeInput", () => { + test("accepts a static Type", () => { + expect().type.toBeAssignableTo(); + }); + + test("accepts a resolver function", () => { + expect< + (chunk: unknown) => ProtobufType + >().type.toBeAssignableTo(); + }); +}); + +describe("protobufEncodeStream", () => { + test("requires Type", () => { + expect(protobufEncodeStream({ Type })).type.not.toBeAssignableTo(); + }); +}); + +describe("protobufDecodeStream", () => { + test("requires Type", () => { + expect(protobufDecodeStream({ Type })).type.not.toBeAssignableTo(); + }); + + test("accepts payload extractor and maxOutputSize", () => { + expect( + protobufDecodeStream<{ data: Uint8Array }>({ + Type, + payload: (chunk) => chunk.data, + maxOutputSize: 1024, + }), + ).type.not.toBeAssignableTo(); + }); +}); + +describe("protobufLengthPrefixFrameStream", () => { + test("accepts no options", () => { + expect( + protobufLengthPrefixFrameStream(), + ).type.not.toBeAssignableTo(); + }); +}); + +describe("protobufLengthPrefixUnframeStream", () => { + test("accepts maxMessageSize", () => { + expect( + protobufLengthPrefixUnframeStream({ maxMessageSize: 1024 }), + ).type.not.toBeAssignableTo(); + }); +}); diff --git a/packages/protobuf/package.json b/packages/protobuf/package.json new file mode 100644 index 0000000..38d3936 --- /dev/null +++ b/packages/protobuf/package.json @@ -0,0 +1,81 @@ +{ + "name": "@datastream/protobuf", + "version": "0.6.1", + "description": "Protobuf encode/decode and length-prefix framing transform streams", + "type": "module", + "engines": { + "node": ">=24" + }, + "engineStrict": true, + "publishConfig": { + "access": "public" + }, + "main": "./index.node.mjs", + "module": "./index.web.mjs", + "exports": { + ".": { + "node": { + "import": { + "types": "./index.d.ts", + "default": "./index.node.mjs" + } + }, + "import": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "./webstream": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "types": "index.d.ts", + "files": [ + "*.mjs", + "*.map", + "*.d.ts" + ], + "scripts": { + "test": "npm run test:unit", + "test:unit": "node --test", + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" + }, + "license": "MIT", + "keywords": [ + "protobuf", + "protobufjs", + "encode", + "decode", + "Web Stream API", + "Node Stream API" + ], + "author": { + "name": "datastream contributors", + "url": "https://github.com/willfarrell/datastream/graphs/contributors" + }, + "repository": { + "type": "git", + "url": "github:willfarrell/datastream", + "directory": "packages/protobuf" + }, + "bugs": { + "url": "https://github.com/willfarrell/datastream/issues" + }, + "homepage": "https://datastream.js.org", + "dependencies": { + "@datastream/core": "0.6.1" + }, + "devDependencies": { + "protobufjs": "^8.0.0" + }, + "peerDependencies": { + "protobufjs": "^8.0.0" + }, + "peerDependenciesMeta": { + "protobufjs": { + "optional": true + } + } +} diff --git a/packages/schema-registry/README.md b/packages/schema-registry/README.md new file mode 100644 index 0000000..3c2a113 --- /dev/null +++ b/packages/schema-registry/README.md @@ -0,0 +1,52 @@ +
+

<datastream> `schema-registry`

+ datastream logo +

Confluent and Glue Schema Registry framing streams.

+

+ GitHub Actions unit test status + GitHub Actions dast test status + GitHub Actions perf test status + GitHub Actions SAST test status + GitHub Actions lint test status +
+ npm version + npm install size + + npm weekly downloads + + npm provenance +
+ Open Source Security Foundation (OpenSSF) Scorecard + SLSA 3 + + Checked with Biome + Conventional Commits + + code coverage +

+

You can read the documentation at: https://datastream.js.org

+
+ + +## Install + +To install datastream you can use NPM: + +```bash +npm install --save @datastream/schema-registry +``` + + +## Documentation and examples + +For documentation and examples, refer to the main [datastream monorepo on GitHub](https://github.com/willfarrell/datastream) or [datastream official website](https://datastream.js.org). + + +## Contributing + +Everyone is very welcome to contribute to this repository. Feel free to [raise issues](https://github.com/willfarrell/datastream/issues) or to [submit Pull Requests](https://github.com/willfarrell/datastream/pulls). + + +## License + +Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell), and [datastream contributors](https://github.com/willfarrell/datastream/graphs/contributors). \ No newline at end of file diff --git a/packages/schema-registry/index.d.ts b/packages/schema-registry/index.d.ts new file mode 100644 index 0000000..cff329c --- /dev/null +++ b/packages/schema-registry/index.d.ts @@ -0,0 +1,54 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import type { + DatastreamTransform, + ResultStream, + StreamOptions, +} from "@datastream/core"; + +export interface ConfluentSchemaIdResult { + schemaId: number | null; +} + +export interface ConfluentEnvelope { + schemaId: number; + payload: Uint8Array; +} + +export interface GlueSchemaResult { + schemaVersionId: string | null; + compression: "none" | "zlib" | null; +} + +export interface GlueEnvelope { + schemaVersionId: string; + compression: "none" | "zlib"; + payload: Uint8Array; +} + +export function confluentFrameStream( + options: { schemaId: number; resultKey?: string }, + streamOptions?: StreamOptions, +): DatastreamTransform & + ResultStream; + +export function confluentUnframeStream( + options?: { resultKey?: string }, + streamOptions?: StreamOptions, +): DatastreamTransform & + ResultStream; + +export function glueFrameStream( + options: { + schemaVersionId: string; + compression?: "none" | "zlib"; + resultKey?: string; + }, + streamOptions?: StreamOptions, +): DatastreamTransform & ResultStream; + +export function glueUnframeStream( + options?: { maxDecompressedBytes?: number; resultKey?: string }, + streamOptions?: StreamOptions, +): DatastreamTransform & + ResultStream; diff --git a/packages/schema-registry/index.fuzz.js b/packages/schema-registry/index.fuzz.js new file mode 100644 index 0000000..653c114 --- /dev/null +++ b/packages/schema-registry/index.fuzz.js @@ -0,0 +1,242 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import test from "node:test"; +import { + createReadableStream, + pipejoin, + pipeline, + streamToArray, +} from "@datastream/core"; +import { + confluentFrameStream, + confluentUnframeStream, + glueFrameStream, + glueUnframeStream, +} from "@datastream/schema-registry"; +import fc from "fast-check"; + +const catchError = (input, e) => { + const expectedErrors = [ + // confluentUnframeStream validation + "confluentUnframeStream: missing 0x00 magic byte / frame is too short", + // glueUnframeStream validation + "glueUnframeStream: missing 0x03 magic byte / frame is too short", + // glueUnframeStream compression errors (dynamic message with hex byte) + "unsupported compression", + // glueUnframeStream decompressed size guard + "maxDecompressedBytes", + // glueFrameStream frame size guard + "maxFrameBytes", + // confluentFrameStream schemaId validation + "confluentFrameStream: schemaId must be an unsigned 32-bit integer", + // glueFrameStream schemaVersionId validation + "glueFrameStream: schemaVersionId required", + // glueFrameStream UUID validation + "UUID", + // glueFrameStream compression validation + "unsupported compression", + // asBytes chunk type validation + "schema-registry: chunk must be a Uint8Array, ArrayBuffer view, ArrayBuffer, or string", + // confluentUnframeStream.result() / glueUnframeStream.result() distinct-id guard + "distinct", + // zlib decompression failures (malformed payload) + "unexpected end of file", + "invalid stored block lengths", + "incorrect header check", + "invalid block type", + "invalid distance too far back", + "invalid literal/length code", + "invalid distance code", + "too many length or distance symbols", + "invalid code lengths set", + "invalid bit length repeat", + "over-subscribed dynamic bit lengths tree", + "incomplete dynamic bit lengths tree", + "empty distance tree with lengths", + "over-subscribed literal/length tree", + "incomplete literal/length tree", + ]; + for (const msg of expectedErrors) { + if (e.message.includes(msg)) return; + } + console.error(input, e); + throw e; +}; + +// *** confluentFrameStream *** // +test("fuzz confluentFrameStream w/ random payload + schemaId", async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 0, maxLength: 256 }), + fc.integer({ min: 0, max: 0xffffffff }), + async (payload, schemaId) => { + try { + const streams = [ + createReadableStream([payload]), + confluentFrameStream({ schemaId }), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError({ payload, schemaId }, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** confluentUnframeStream *** // +test("fuzz confluentUnframeStream w/ random bytes", async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 0, maxLength: 256 }), + async (input) => { + try { + await pipeline([ + createReadableStream([input]), + confluentUnframeStream(), + ]); + } catch (e) { + catchError(input, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** confluent frame -> unframe roundtrip *** // +test("fuzz confluent frame -> unframe roundtrip", async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 0, maxLength: 256 }), + fc.integer({ min: 0, max: 0xffffffff }), + async (payload, schemaId) => { + try { + const streams = [ + createReadableStream([payload]), + confluentFrameStream({ schemaId }), + confluentUnframeStream(), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError({ payload, schemaId }, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** glueFrameStream *** // +test("fuzz glueFrameStream w/ random payload + UUID", async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 0, maxLength: 256 }), + fc.uuid(), + async (payload, schemaVersionId) => { + try { + const streams = [ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId }), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError({ payload, schemaVersionId }, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** glueUnframeStream *** // +test("fuzz glueUnframeStream w/ random bytes", async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 0, maxLength: 256 }), + async (input) => { + try { + await pipeline([createReadableStream([input]), glueUnframeStream()]); + } catch (e) { + catchError(input, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** glue frame -> unframe roundtrip (none compression) *** // +test("fuzz glue frame -> unframe roundtrip (none)", async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 0, maxLength: 256 }), + fc.uuid(), + async (payload, schemaVersionId) => { + try { + const streams = [ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId }), + glueUnframeStream(), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError({ payload, schemaVersionId }, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); + +// *** glue frame -> unframe roundtrip (zlib compression) *** // +test("fuzz glue frame -> unframe roundtrip (zlib)", async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({ minLength: 0, maxLength: 256 }), + fc.uuid(), + async (payload, schemaVersionId) => { + try { + const streams = [ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId, compression: "zlib" }), + glueUnframeStream(), + ]; + await streamToArray(pipejoin(streams)); + } catch (e) { + catchError({ payload, schemaVersionId }, e); + } + }, + ), + { + numRuns: 1_000, + verbose: 2, + examples: [], + }, + ); +}); diff --git a/packages/schema-registry/index.js b/packages/schema-registry/index.js new file mode 100644 index 0000000..05e7f14 --- /dev/null +++ b/packages/schema-registry/index.js @@ -0,0 +1,380 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +/* global CompressionStream, DecompressionStream */ +import { createTransformStream } from "@datastream/core"; + +// Each frame/unframe transform treats one input chunk as one Schema Registry +// envelope. This matches the Kafka use case (one Kafka message = one chunk = +// one Schema Registry-framed record). Callers piping concatenated framed +// buffers must split them upstream (e.g. via a length-prefix transform). +// +// Unframe streams emit { schemaId | schemaVersionId, payload } envelopes +// downstream so downstream decoders (e.g. protobufDecodeStream) can pick the +// right schema per chunk without sharing mutable state via `.result()`. The +// `.result()` accessor is still exposed for parity with the csvDetect pattern, +// but reflects the *most recently seen* envelope and is racy under +// backpressure — prefer the per-chunk envelope when wiring a decoder. + +const GLUE_COMPRESSION_NONE = 0x00; +const GLUE_COMPRESSION_ZLIB = 0x05; +const UUID_HEX_RE = /^[0-9a-fA-F]{32}$/; + +// Shared no-op for swallowing secondary promise rejections in error-handling +// catch blocks (see inflate/deflate). Using a named reference avoids creating +// uncalled anonymous functions that inflate function-coverage miss counts. +const noop = () => {}; + +const asBytes = (chunk) => { + if (typeof chunk === "string") return new TextEncoder().encode(chunk); + // ArrayBuffer.isView covers typed arrays / DataView with possible byteOffset + // (including Uint8Array — the resulting view is byte-identical to the input). + if (ArrayBuffer.isView(chunk)) { + return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk); + // Reject anything else (numbers, null, plain objects). The previous + // `new Uint8Array(chunk.buffer ?? chunk)` silently turned a number N into an + // N-byte zero-filled buffer instead of erroring. + throw new TypeError( + "schema-registry: chunk must be a Uint8Array, ArrayBuffer view, ArrayBuffer, or string", + ); +}; + +const concat = (parts) => { + let total = 0; + for (const p of parts) total += p.byteLength; + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.byteLength; + } + return out; +}; + +// Reassemble a single logical frame that createReadableStream may have +// auto-chunked into multiple sub-chunks. Schema Registry envelopes carry no +// embedded length, so we use the magic byte + minimum header length as the +// frame-boundary signal: a chunk that begins with `magic` and is at least +// `headerSize` bytes long starts a NEW frame; any other chunk is a +// continuation of the frame currently being buffered. This keeps the +// "one complete framed record per chunk" contract working (each such chunk +// starts with the magic byte) while correctly reassembling a >chunkSize frame +// that createReadableStream sliced into payload-only continuation chunks. +const createFrameBuffer = (magic, headerSize) => { + let parts = null; // null => no frame in progress + return { + // Returns the completed previous frame (or undefined) and starts buffering + // the incoming chunk. + push(bytes) { + let completed; + const startsNewFrame = + bytes.byteLength >= headerSize && bytes[0] === magic; + if (startsNewFrame) { + // Emit the previously buffered frame (if any) before starting fresh. + // concat handles the common single-part case correctly too. + if (parts !== null) completed = concat(parts); + parts = [bytes]; + } else { + // Continuation chunk. If nothing is in progress this is a malformed + // lead chunk; surface it to the caller as `undefined` start + push so + // the caller's header validation throws the right error. + if (parts === null) parts = [bytes]; + else parts.push(bytes); + } + return completed; + }, + flush() { + if (parts === null) return undefined; + const completed = concat(parts); + parts = null; + return completed; + }, + }; +}; + +const collectStream = async (readable, maxOutputSize, limitName) => { + const reader = readable.getReader(); + const chunks = []; + let total = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (maxOutputSize != null && total > maxOutputSize) { + await reader.cancel(); + throw new Error( + `schema-registry: ${limitName} exceeded (${maxOutputSize})`, + ); + } + chunks.push(value); + } + return concat(chunks); +}; + +const inflate = async (bytes, maxOutputSize) => { + const ds = new DecompressionStream("deflate"); + const writer = ds.writable.getWriter(); + // Errors on the write side surface via the read side; we await both so + // failures (e.g. malformed zlib, backpressure aborts) propagate cleanly. + const writeP = writer.write(bytes); + const closeP = writer.close(); + try { + const result = await collectStream( + ds.readable, + maxOutputSize, + "maxDecompressedBytes", + ); + await writeP; + await closeP; + return result; + } catch (err) { + // Swallow writeP/closeP rejections after collect throws — read-side error + // is the underlying cause. + writeP.catch(noop); + closeP.catch(noop); + throw err; + } +}; + +const deflate = async (bytes, maxOutputSize) => { + const cs = new CompressionStream("deflate"); + const writer = cs.writable.getWriter(); + const writeP = writer.write(bytes); + const closeP = writer.close(); + try { + const result = await collectStream( + cs.readable, + maxOutputSize, + "maxFrameBytes", + ); + await writeP; + await closeP; + return result; + } catch (err) { + writeP.catch(noop); + closeP.catch(noop); + throw err; + } +}; + +// *** Confluent (5-byte: 0x00 magic + uint32 BE schema id) *** // + +export const confluentFrameStream = ( + { schemaId, resultKey } = {}, + streamOptions = {}, +) => { + if ( + // Number.isInteger is false for every non-number, so it already rejects + // strings/bigints/etc.; an explicit typeof check would be redundant. + !Number.isInteger(schemaId) || + schemaId < 0 || + schemaId > 0xffffffff + ) { + throw new TypeError( + "confluentFrameStream: schemaId must be an unsigned 32-bit integer", + ); + } + const header = new Uint8Array(5); + header[0] = 0x00; + new DataView(header.buffer).setUint32(1, schemaId, false); + const value = { schemaId }; + const transform = (chunk, enqueue) => { + enqueue(concat([header, asBytes(chunk)])); + }; + const stream = createTransformStream(transform, streamOptions); + // For frame streams `.result()` echoes the schemaId the caller configured + // (not detected). Listed under "confluentSchemaId" for symmetry with + // unframe — see file header note. + stream.result = () => ({ + key: resultKey ?? "confluentSchemaId", + value, + }); + return stream; +}; + +export const confluentUnframeStream = ( + { resultKey } = {}, + streamOptions = {}, +) => { + const value = { schemaId: null }; + let distinctIds = 0; + const frameBuffer = createFrameBuffer(0x00, 5); + const parseAndEmit = (bytes, enqueue) => { + if (bytes.byteLength < 5 || bytes[0] !== 0x00) { + throw new Error( + "confluentUnframeStream: missing 0x00 magic byte / frame is too short", + ); + } + const schemaId = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(1, false); + if (value.schemaId !== schemaId) distinctIds += 1; + value.schemaId = schemaId; + // subarray (zero-copy) — the source bytes outlive the chunk. + enqueue({ schemaId, payload: bytes.subarray(5) }); + }; + const transform = (chunk, enqueue) => { + const completed = frameBuffer.push(asBytes(chunk)); + if (completed !== undefined) parseAndEmit(completed, enqueue); + }; + const flush = (enqueue) => { + const completed = frameBuffer.flush(); + if (completed !== undefined) parseAndEmit(completed, enqueue); + }; + const stream = createTransformStream(transform, flush, streamOptions); + // `.result()` reflects the most-recently-seen envelope and is racy under + // backpressure (see file header). If more than one DISTINCT schema id was + // observed, the single shared value is meaningless, so throw rather than + // silently report a stale id — the per-chunk envelope is the correct API. + stream.result = () => { + if (distinctIds > 1) { + throw new Error( + "confluentUnframeStream.result(): stream carried multiple distinct schemaIds; use the per-chunk envelope { schemaId, payload } instead", + ); + } + return { key: resultKey ?? "confluentSchemaId", value }; + }; + return stream; +}; + +// *** Glue (18-byte: 0x03 magic + 1 byte compression + 16 bytes UUID) *** // + +const uuidToBytes = (uuid) => { + const hex = uuid.replaceAll("-", ""); + if (!UUID_HEX_RE.test(hex)) { + throw new TypeError( + `glueFrameStream: schemaVersionId must be a valid UUID (got ${uuid})`, + ); + } + // hex is exactly 32 chars (UUID_HEX_RE), i.e. 16 byte-pairs. Collect into a + // plain array first so an over-long loop produces a >16-byte result (caught + // downstream by header.set) instead of a silently-ignored out-of-bounds write + // on a fixed-size typed array. + const out = []; + for (let i = 0; i < 16; i++) { + out.push(Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)); + } + return new Uint8Array(out); +}; + +// Precomputed byte -> 2-char hex so bytesToUuid (called per unframed Glue +// message) avoids a per-byte toString(16)+padStart and the 5 trailing slices: +// index the table and concatenate the canonical 8-4-4-4-12 layout directly. +const HEX_BYTE = Array.from({ length: 256 }, (_, i) => + i.toString(16).padStart(2, "0"), +); +const bytesToUuid = (bytes, offset) => { + const h = HEX_BYTE; + return ( + `${h[bytes[offset]]}${h[bytes[offset + 1]]}${h[bytes[offset + 2]]}${h[bytes[offset + 3]]}-` + + `${h[bytes[offset + 4]]}${h[bytes[offset + 5]]}-` + + `${h[bytes[offset + 6]]}${h[bytes[offset + 7]]}-` + + `${h[bytes[offset + 8]]}${h[bytes[offset + 9]]}-` + + `${h[bytes[offset + 10]]}${h[bytes[offset + 11]]}${h[bytes[offset + 12]]}${h[bytes[offset + 13]]}${h[bytes[offset + 14]]}${h[bytes[offset + 15]]}` + ); +}; + +export const glueFrameStream = ( + { schemaVersionId, compression = "none", maxFrameBytes, resultKey } = {}, + streamOptions = {}, +) => { + if (typeof schemaVersionId !== "string") { + throw new TypeError("glueFrameStream: schemaVersionId required"); + } + if (compression !== "none" && compression !== "zlib") { + throw new TypeError( + `glueFrameStream: unsupported compression "${compression}" (expected "none" or "zlib")`, + ); + } + const uuid = uuidToBytes(schemaVersionId); + const compressionByte = + compression === "zlib" ? GLUE_COMPRESSION_ZLIB : GLUE_COMPRESSION_NONE; + const value = { schemaVersionId }; + // Pre-built 18-byte header — reused across chunks (only payload differs). + const header = new Uint8Array(18); + header[0] = 0x03; + header[1] = compressionByte; + header.set(uuid, 2); + + const transform = async (chunk, enqueue) => { + const bytes = asBytes(chunk); + // deflate() buffers the whole compressed result; bound it with the + // optional maxFrameBytes ceiling, mirroring glueUnframeStream's + // maxDecompressedBytes (no other buffering path here is unbounded). + const payload = + compression === "zlib" ? await deflate(bytes, maxFrameBytes) : bytes; + enqueue(concat([header, payload])); + }; + const stream = createTransformStream(transform, streamOptions); + stream.result = () => ({ + key: resultKey ?? "glueSchemaVersionId", + value, + }); + return stream; +}; + +export const glueUnframeStream = ( + { maxDecompressedBytes = 10 * 1024 * 1024, resultKey } = {}, + streamOptions = {}, +) => { + const value = { schemaVersionId: null, compression: null }; + let distinctIds = 0; + const frameBuffer = createFrameBuffer(0x03, 18); + const parseAndEmit = async (bytes, enqueue) => { + if (bytes.byteLength < 18 || bytes[0] !== 0x03) { + throw new Error( + "glueUnframeStream: missing 0x03 magic byte / frame is too short", + ); + } + const compressionByte = bytes[1]; + const schemaVersionId = bytesToUuid(bytes, 2); + if (value.schemaVersionId !== schemaVersionId) distinctIds += 1; + value.schemaVersionId = schemaVersionId; + const framedPayload = bytes.subarray(18); + let payload; + let kind; + if (compressionByte === GLUE_COMPRESSION_NONE) { + kind = "none"; + payload = framedPayload; + } else if (compressionByte === GLUE_COMPRESSION_ZLIB) { + kind = "zlib"; + payload = await inflate(framedPayload, maxDecompressedBytes); + } else { + throw new Error( + `glueUnframeStream: unsupported compression byte 0x${compressionByte.toString(16).padStart(2, "0")}`, + ); + } + value.compression = kind; + enqueue({ schemaVersionId, compression: kind, payload }); + }; + const transform = async (chunk, enqueue) => { + const completed = frameBuffer.push(asBytes(chunk)); + if (completed !== undefined) await parseAndEmit(completed, enqueue); + }; + const flush = async (enqueue) => { + const completed = frameBuffer.flush(); + if (completed !== undefined) await parseAndEmit(completed, enqueue); + }; + const stream = createTransformStream(transform, flush, streamOptions); + // See confluentUnframeStream: throw rather than report a stale id when the + // stream carried multiple distinct schemaVersionIds. + stream.result = () => { + if (distinctIds > 1) { + throw new Error( + "glueUnframeStream.result(): stream carried multiple distinct schemaVersionIds; use the per-chunk envelope { schemaVersionId, compression, payload } instead", + ); + } + return { key: resultKey ?? "glueSchemaVersionId", value }; + }; + return stream; +}; + +export default { + confluentFrameStream, + confluentUnframeStream, + glueFrameStream, + glueUnframeStream, +}; diff --git a/packages/schema-registry/index.perf.js b/packages/schema-registry/index.perf.js new file mode 100644 index 0000000..d366a88 --- /dev/null +++ b/packages/schema-registry/index.perf.js @@ -0,0 +1,142 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import test from "node:test"; +import { + createReadableStream, + pipejoin, + streamToArray, +} from "@datastream/core"; +import { + confluentFrameStream, + confluentUnframeStream, + glueFrameStream, + glueUnframeStream, +} from "@datastream/schema-registry"; +import { Bench } from "tinybench"; + +// -- Data generators -- + +const time = Number(process.env.BENCH_TIME ?? 5_000); + +const schemaId = 42; +const schemaVersionId = "12345678-1234-1234-1234-1234567890ab"; +const count = 10_000; + +// Generate N Uint8Array payloads of 64 bytes each +const messages = Array.from({ length: count }, (_, i) => { + const buf = new Uint8Array(64); + for (let j = 0; j < 64; j++) buf[j] = (i + j) % 256; + return buf; +}); + +// Pre-frame messages for unframe benchmarks +const confluentFramed = await streamToArray( + pipejoin([ + createReadableStream(messages), + confluentFrameStream({ schemaId }), + ]), +); + +const glueFramed = await streamToArray( + pipejoin([ + createReadableStream(messages), + glueFrameStream({ schemaVersionId }), + ]), +); + +// -- Tests -- + +test("perf: confluentFrameStream", async () => { + const bench = new Bench({ name: "confluentFrameStream", time }); + + bench.add(`${count} messages`, async () => { + const streams = [ + createReadableStream(messages), + confluentFrameStream({ schemaId }), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: confluentUnframeStream", async () => { + const bench = new Bench({ name: "confluentUnframeStream", time }); + + bench.add(`${count} messages`, async () => { + const streams = [ + createReadableStream(confluentFramed), + confluentUnframeStream(), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: confluent frame -> unframe roundtrip", async () => { + const bench = new Bench({ name: "confluent roundtrip", time }); + + bench.add(`${count} messages`, async () => { + const streams = [ + createReadableStream(messages), + confluentFrameStream({ schemaId }), + confluentUnframeStream(), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: glueFrameStream", async () => { + const bench = new Bench({ name: "glueFrameStream", time }); + + bench.add(`${count} messages`, async () => { + const streams = [ + createReadableStream(messages), + glueFrameStream({ schemaVersionId }), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: glueUnframeStream", async () => { + const bench = new Bench({ name: "glueUnframeStream", time }); + + bench.add(`${count} messages`, async () => { + const streams = [createReadableStream(glueFramed), glueUnframeStream()]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: glue frame -> unframe roundtrip", async () => { + const bench = new Bench({ name: "glue roundtrip", time }); + + bench.add(`${count} messages`, async () => { + const streams = [ + createReadableStream(messages), + glueFrameStream({ schemaVersionId }), + glueUnframeStream(), + ]; + await streamToArray(pipejoin(streams)); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); diff --git a/packages/schema-registry/index.test.js b/packages/schema-registry/index.test.js new file mode 100644 index 0000000..5756260 --- /dev/null +++ b/packages/schema-registry/index.test.js @@ -0,0 +1,1377 @@ +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import test from "node:test"; + +import { + createReadableStream, + pipejoin, + pipeline, + streamToArray, +} from "@datastream/core"; +import { + confluentFrameStream, + confluentUnframeStream, + glueFrameStream, + glueUnframeStream, +} from "@datastream/schema-registry"; + +let variant = "unknown"; +for (const execArgv of process.execArgv) { + const flag = "--conditions="; + if (execArgv.includes(flag)) { + variant = execArgv.replace(flag, ""); + } +} + +const helloWorld = new TextEncoder().encode("hello world"); +const schemaUuid = "12345678-1234-1234-1234-1234567890ab"; + +// *** Confluent *** // +test(`${variant}: confluentFrameStream prepends 0x00 + uint32 BE schema id`, async () => { + const streams = [ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 257 }), + ]; + const out = await streamToArray(pipejoin(streams)); + strictEqual(out.length, 1); + strictEqual(out[0][0], 0x00); + // 257 = 0x00000101 + strictEqual(out[0][1], 0x00); + strictEqual(out[0][2], 0x00); + strictEqual(out[0][3], 0x01); + strictEqual(out[0][4], 0x01); + strictEqual(out[0].byteLength, 5 + helloWorld.byteLength); +}); + +test(`${variant}: confluentFrameStream encodes schemaId big-endian for >24-bit values`, async () => { + const streams = [ + createReadableStream([new Uint8Array([0xaa])]), + confluentFrameStream({ schemaId: 0x12345678 }), + ]; + const out = await streamToArray(pipejoin(streams)); + deepStrictEqual( + Array.from(out[0].slice(0, 5)), + [0x00, 0x12, 0x34, 0x56, 0x78], + ); +}); + +test(`${variant}: confluent frame -> unframe round-trip emits envelope with schemaId + payload`, async () => { + const unframe = confluentUnframeStream(); + const streams = [ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 42 }), + unframe, + ]; + const out = await streamToArray(pipejoin(streams)); + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 42); + deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld)); + // .result() still works for compatibility with the csvDetect-style readers. + strictEqual(unframe.result().value.schemaId, 42); +}); + +test(`${variant}: confluentUnframeStream rejects frames missing the magic byte`, async () => { + const bogus = new Uint8Array([0x01, 0, 0, 0, 0, 0x68, 0x69]); + try { + await pipeline([createReadableStream([bogus]), confluentUnframeStream()]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("magic byte")); + } +}); + +// *** Glue uncompressed *** // +test(`${variant}: glueFrameStream prepends 0x03 + compression byte + uuid`, async () => { + const streams = [ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: schemaUuid }), + ]; + const out = await streamToArray(pipejoin(streams)); + strictEqual(out[0][0], 0x03); + strictEqual(out[0][1], 0x00); // compression none + strictEqual(out[0].byteLength, 18 + helloWorld.byteLength); +}); + +test(`${variant}: glue frame -> unframe round-trip emits { schemaVersionId, compression, payload }`, async () => { + const unframe = glueUnframeStream(); + const streams = [ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: schemaUuid }), + unframe, + ]; + const out = await streamToArray(pipejoin(streams)); + strictEqual(out[0].schemaVersionId, schemaUuid); + strictEqual(out[0].compression, "none"); + deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld)); + strictEqual(unframe.result().value.schemaVersionId, schemaUuid); + strictEqual(unframe.result().value.compression, "none"); +}); + +// *** Glue zlib *** // +test(`${variant}: glue frame -> unframe round-trip (zlib compressed)`, async () => { + const payload = new TextEncoder().encode("a".repeat(2048)); + const unframe = glueUnframeStream(); + const streams = [ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }), + unframe, + ]; + const out = await streamToArray(pipejoin(streams)); + strictEqual(out[0].compression, "zlib"); + deepStrictEqual(Array.from(out[0].payload), Array.from(payload)); +}); + +test(`${variant}: glueUnframeStream enforces maxDecompressedBytes`, async () => { + const payload = new TextEncoder().encode("a".repeat(2048)); + try { + await pipeline([ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }), + glueUnframeStream({ maxDecompressedBytes: 100 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxDecompressedBytes")); + } +}); + +test(`${variant}: glueUnframeStream rejects unknown compression byte`, async () => { + const bogus = new Uint8Array(18 + 4); + bogus[0] = 0x03; + bogus[1] = 0x09; // unsupported + try { + await pipeline([createReadableStream([bogus]), glueUnframeStream()]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("unsupported compression")); + } +}); + +// *** input validation *** // +test(`${variant}: confluentFrameStream rejects non-integer / out-of-range schemaId`, () => { + for (const bad of [undefined, "1", -1, 1.5, null, 0x100000000]) { + try { + confluentFrameStream({ schemaId: bad }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("schemaId")); + } + } +}); + +test(`${variant}: glueFrameStream rejects missing schemaVersionId`, () => { + try { + glueFrameStream({}); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("schemaVersionId")); + } +}); + +test(`${variant}: glueFrameStream rejects unsupported compression`, () => { + try { + glueFrameStream({ schemaVersionId: schemaUuid, compression: "gzip" }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("compression")); + } +}); + +test(`${variant}: glueFrameStream rejects malformed schemaVersionId UUID`, () => { + for (const bad of [ + "not-a-uuid", + "zzzzzzzz-1234-1234-1234-1234567890ab", // non-hex + "1234567812341234123412345678ab", // too short + ]) { + try { + glueFrameStream({ schemaVersionId: bad }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("UUID")); + } + } +}); + +test(`${variant}: glueUnframeStream rejects frames missing the magic byte`, async () => { + const bogus = new Uint8Array(20); + bogus[0] = 0x07; + try { + await pipeline([createReadableStream([bogus]), glueUnframeStream()]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("magic byte")); + } +}); + +// *** asBytes input-type handling (finding: numeric chunk -> zero-filled buffer) *** // +test(`${variant}: confluentUnframeStream accepts string chunks`, async () => { + // Build a framed Confluent envelope, then feed it back as a latin1 string. + const framed = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 7 }), + ]), + ); + const asString = Array.from(framed[0]) + .map((b) => String.fromCharCode(b)) + .join(""); + // Re-encode via TextEncoder would corrupt high bytes; helloWorld is ASCII so + // round-trips, and the header bytes are < 0x80, so a latin1 string is exact. + const out = await streamToArray( + pipejoin([createReadableStream([asString]), confluentUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 7); + deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld)); +}); + +test(`${variant}: confluentUnframeStream accepts ArrayBuffer view with non-zero byteOffset`, async () => { + const framed = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 9 }), + ]), + ); + // Place the frame inside a larger buffer at a non-zero offset, then hand a + // subarray view (byteOffset > 0) to the unframe stream. + const backing = new Uint8Array(framed[0].byteLength + 3); + backing.set(framed[0], 3); + const view = backing.subarray(3); + const out = await streamToArray( + pipejoin([createReadableStream([view]), confluentUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 9); + deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld)); +}); + +test(`${variant}: confluentFrameStream throws on a numeric chunk instead of framing zero bytes`, async () => { + try { + await pipeline([ + createReadableStream([5]), + confluentFrameStream({ schemaId: 1 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("chunk must be")); + } +}); + +// *** Glue zlib failure / round-trip edge cases *** // +test(`${variant}: glueUnframeStream rejects a malformed zlib payload`, async () => { + // magic 0x03 + compression 0x05 (zlib) + 16-byte UUID + garbage payload. + const frame = new Uint8Array(18 + 4); + frame[0] = 0x03; + frame[1] = 0x05; + frame.set([0xde, 0xad, 0xbe, 0xef], 18); + try { + await pipeline([createReadableStream([frame]), glueUnframeStream()]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e instanceof Error); + ok(!e.message.includes("Should have thrown")); + } +}); + +test(`${variant}: glue frame -> unframe round-trip (zlib, empty payload)`, async () => { + const empty = new Uint8Array(0); + const out = await streamToArray( + pipejoin([ + createReadableStream([empty]), + glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }), + glueUnframeStream(), + ]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].compression, "zlib"); + strictEqual(out[0].payload.byteLength, 0); +}); + +test(`${variant}: glueFrameStream deflate enforces maxFrameBytes ceiling`, async () => { + const payload = new TextEncoder().encode("a".repeat(4096)); + try { + await pipeline([ + createReadableStream([payload]), + glueFrameStream({ + schemaVersionId: schemaUuid, + compression: "zlib", + maxFrameBytes: 10, + }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxFrameBytes")); + } +}); + +// *** .result() raciness guard *** // +test(`${variant}: confluentUnframeStream.result() throws when multiple distinct schema ids are seen`, async () => { + const frameA = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 1 }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 2 }), + ]), + ); + const unframe = confluentUnframeStream(); + await streamToArray( + pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]), + ); + try { + await unframe.result(); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("distinct")); + } +}); + +// *** multi-chunk reassembly: a single frame split across chunks *** // +test(`${variant}: confluent frame -> unframe round-trips a single frame auto-chunked over 16KB`, async () => { + // 40KB payload; createReadableStream auto-chunks the framed buffer into + // multiple 16KB sub-chunks of ONE frame. + const big = new Uint8Array(40 * 1024); + for (let i = 0; i < big.byteLength; i++) big[i] = i % 251; + const framed = await streamToArray( + pipejoin([ + createReadableStream([big]), + confluentFrameStream({ schemaId: 123 }), + ]), + ); + ok(framed[0].byteLength > 16384); + const out = await streamToArray( + pipejoin([ + createReadableStream(framed[0]), // ArrayBuffer path -> sub-chunks + confluentUnframeStream(), + ]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 123); + strictEqual(out[0].payload.byteLength, big.byteLength); + deepStrictEqual(Array.from(out[0].payload), Array.from(big)); +}); + +test(`${variant}: glue frame -> unframe round-trips a single frame auto-chunked over 16KB`, async () => { + const big = new Uint8Array(40 * 1024); + for (let i = 0; i < big.byteLength; i++) big[i] = (i * 7) % 251; + const framed = await streamToArray( + pipejoin([ + createReadableStream([big]), + glueFrameStream({ schemaVersionId: schemaUuid }), + ]), + ); + ok(framed[0].byteLength > 16384); + const out = await streamToArray( + pipejoin([createReadableStream(framed[0]), glueUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaVersionId, schemaUuid); + strictEqual(out[0].payload.byteLength, big.byteLength); + deepStrictEqual(Array.from(out[0].payload), Array.from(big)); +}); + +// *** envelope shape protects against per-message schemaVersionId drift *** // +test(`${variant}: glueUnframeStream envelope correctly pairs payload with its own schemaVersionId`, async () => { + const idA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + const idB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + const frameA = await streamToArray( + pipejoin([ + createReadableStream([new TextEncoder().encode("payload-a")]), + glueFrameStream({ schemaVersionId: idA }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([new TextEncoder().encode("payload-b")]), + glueFrameStream({ schemaVersionId: idB }), + ]), + ); + const envelopes = await streamToArray( + pipejoin([ + createReadableStream([frameA[0], frameB[0]]), + glueUnframeStream(), + ]), + ); + strictEqual(envelopes.length, 2); + strictEqual(envelopes[0].schemaVersionId, idA); + strictEqual(envelopes[1].schemaVersionId, idB); + deepStrictEqual(new TextDecoder().decode(envelopes[0].payload), "payload-a"); + deepStrictEqual(new TextDecoder().decode(envelopes[1].payload), "payload-b"); +}); + +// *** UUID regex anchoring: ^ and $ must both match (not substring) *** // +test(`${variant}: glueFrameStream rejects a 33-char hex string (fails $ anchor on UUID regex)`, () => { + // After replaceAll("-",""), 33 hex chars is not 32 - both anchors must hold. + const tooLong = "0123456789abcdef0123456789abcdef0"; // 33 hex chars, no hyphens + try { + glueFrameStream({ schemaVersionId: tooLong }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("UUID"), `Expected UUID error, got: ${e.message}`); + } + const alsoTooLong = "012345678901234567890123456789abc"; // 33 hex chars + try { + glueFrameStream({ schemaVersionId: alsoTooLong }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("UUID"), `Expected UUID error, got: ${e.message}`); + } +}); + +// *** asBytes: ArrayBuffer.isView path (non-Uint8Array typed array) *** // +test(`${variant}: confluentFrameStream accepts an Int16Array (ArrayBuffer.isView branch)`, async () => { + // Int16Array is an ArrayBuffer view but NOT a Uint8Array - hits ArrayBuffer.isView branch. + const int16 = new Int16Array([1, 2, 3, 4]); + const out = await streamToArray( + pipejoin([ + createReadableStream([int16]), + confluentFrameStream({ schemaId: 55 }), + ]), + ); + strictEqual(out.length, 1); + strictEqual(out[0][0], 0x00); // magic byte + strictEqual(out[0].byteLength, 5 + int16.byteLength); +}); + +// *** asBytes: plain ArrayBuffer path *** // +test(`${variant}: confluentFrameStream accepts a plain ArrayBuffer`, async () => { + // Plain ArrayBuffer (not a view) hits the instanceof ArrayBuffer branch. + const buf = new Uint8Array([0x61, 0x62, 0x63]).buffer; // "abc" + const out = await streamToArray( + pipejoin([ + createReadableStream([buf]), + confluentFrameStream({ schemaId: 77 }), + ]), + ); + strictEqual(out.length, 1); + strictEqual(out[0][0], 0x00); + strictEqual(out[0].byteLength, 5 + 3); + // Verify payload bytes are "abc" + strictEqual(out[0][5], 0x61); + strictEqual(out[0][6], 0x62); + strictEqual(out[0][7], 0x63); +}); + +// *** asBytes: Uint8Array fast-path returns the chunk identity *** // +test(`${variant}: asBytes Uint8Array fast-path preserves the original bytes exactly`, async () => { + // Exercise the first branch in asBytes: Uint8Array -> return chunk directly. + const bytes = new Uint8Array([0x41, 0x42, 0x43]); + const out = await streamToArray( + pipejoin([ + createReadableStream([bytes]), + confluentFrameStream({ schemaId: 0 }), + ]), + ); + strictEqual(out[0][5], 0x41); + strictEqual(out[0][6], 0x42); + strictEqual(out[0][7], 0x43); +}); + +// *** confluentFrameStream schemaId boundary values *** // +test(`${variant}: confluentFrameStream accepts schemaId === 0 (minimum valid, exact boundary)`, async () => { + const out = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 0 }), + ]), + ); + strictEqual(out[0][1], 0x00); + strictEqual(out[0][2], 0x00); + strictEqual(out[0][3], 0x00); + strictEqual(out[0][4], 0x00); +}); + +test(`${variant}: confluentFrameStream accepts schemaId === 0xffffffff (maximum valid, exact boundary)`, async () => { + const out = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 0xffffffff }), + ]), + ); + strictEqual(out[0][1], 0xff); + strictEqual(out[0][2], 0xff); + strictEqual(out[0][3], 0xff); + strictEqual(out[0][4], 0xff); +}); + +test(`${variant}: confluentFrameStream rejects schemaId === 0x100000000 (one above max)`, () => { + try { + confluentFrameStream({ schemaId: 0x100000000 }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("schemaId"), + `Expected schemaId error, got: ${e.message}`, + ); + } +}); + +// *** confluentFrameStream typeof validation: number type check *** // +test(`${variant}: confluentFrameStream rejects schemaId that is a number string (type check)`, () => { + try { + confluentFrameStream({ schemaId: "42" }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("schemaId"), + `Expected schemaId error, got: ${e.message}`, + ); + } +}); + +// *** confluentFrameStream uses big-endian encoding (setUint32 false) *** // +test(`${variant}: confluentFrameStream header bytes are big-endian (setUint32 little=false)`, async () => { + // 0x01020304 big-endian => bytes [0x01, 0x02, 0x03, 0x04] + const out = await streamToArray( + pipejoin([ + createReadableStream([new Uint8Array([0xff])]), + confluentFrameStream({ schemaId: 0x01020304 }), + ]), + ); + strictEqual(out[0][1], 0x01); + strictEqual(out[0][2], 0x02); + strictEqual(out[0][3], 0x03); + strictEqual(out[0][4], 0x04); +}); + +// *** confluentUnframeStream: frame exactly 4 bytes -> too short *** // +test(`${variant}: confluentUnframeStream rejects a frame that is exactly 4 bytes (byteLength < 5)`, async () => { + const short = new Uint8Array([0x00, 0x00, 0x00, 0x01]); // valid magic, but only 4 bytes + try { + await pipeline([createReadableStream([short]), confluentUnframeStream()]); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("magic byte"), + `Expected magic byte error, got: ${e.message}`, + ); + } +}); + +// *** confluentUnframeStream: schemaId read as big-endian (getUint32 false) *** // +test(`${variant}: confluentUnframeStream reads schemaId as big-endian (not little-endian)`, async () => { + // magic 0x00 + [0x01, 0x02, 0x03, 0x04] big-endian = 0x01020304 + const frame = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04, 0xaa]); + const out = await streamToArray( + pipejoin([createReadableStream([frame]), confluentUnframeStream()]), + ); + strictEqual(out[0].schemaId, 0x01020304); + strictEqual(out[0].payload[0], 0xaa); +}); + +// *** confluentUnframeStream distinctIds tracking *** // +test(`${variant}: confluentUnframeStream.result() succeeds when exactly 1 distinct schemaId is seen`, async () => { + const unframe = confluentUnframeStream(); + const frameA = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 5 }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([new Uint8Array([0x01])]), + confluentFrameStream({ schemaId: 5 }), // same id + ]), + ); + await streamToArray( + pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]), + ); + const result = unframe.result(); + strictEqual(result.value.schemaId, 5); +}); + +test(`${variant}: confluentUnframeStream.result() throws when exactly 2 distinct schemaIds are seen`, async () => { + const frameA = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 10 }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 11 }), + ]), + ); + const unframe = confluentUnframeStream(); + await streamToArray( + pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]), + ); + try { + unframe.result(); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("distinct"), + `Expected distinct error, got: ${e.message}`, + ); + ok( + e.message.includes("schemaId"), + `Expected schemaId in error, got: ${e.message}`, + ); + } +}); + +// *** confluentUnframeStream.result() uses resultKey when provided *** // +test(`${variant}: confluentUnframeStream.result() uses custom resultKey`, async () => { + const unframe = confluentUnframeStream({ resultKey: "myKey" }); + await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 3 }), + unframe, + ]), + ); + strictEqual(unframe.result().key, "myKey"); +}); + +test(`${variant}: confluentUnframeStream.result() defaults resultKey to "confluentSchemaId"`, async () => { + const unframe = confluentUnframeStream(); + await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 3 }), + unframe, + ]), + ); + strictEqual(unframe.result().key, "confluentSchemaId"); +}); + +// *** confluentFrameStream.result() resultKey *** // +test(`${variant}: confluentFrameStream.result() uses custom resultKey`, async () => { + const stream = confluentFrameStream({ + schemaId: 7, + resultKey: "myConfluentKey", + }); + await streamToArray(pipejoin([createReadableStream([helloWorld]), stream])); + strictEqual(stream.result().key, "myConfluentKey"); +}); + +test(`${variant}: confluentFrameStream.result() defaults to "confluentSchemaId"`, async () => { + const stream = confluentFrameStream({ schemaId: 7 }); + await streamToArray(pipejoin([createReadableStream([helloWorld]), stream])); + strictEqual(stream.result().key, "confluentSchemaId"); + strictEqual(stream.result().value.schemaId, 7); +}); + +// *** collectStream maxOutputSize boundary: total > maxOutputSize, not >= *** // +test(`${variant}: glueUnframeStream allows decompressed output exactly equal to maxDecompressedBytes`, async () => { + const payload = new TextEncoder().encode("a".repeat(100)); + const out = await streamToArray( + pipejoin([ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }), + glueUnframeStream({ maxDecompressedBytes: 100 }), + ]), + ); + strictEqual(out[0].payload.byteLength, 100); +}); + +// *** collectStream: maxOutputSize null means no limit *** // +test(`${variant}: glueUnframeStream with default maxDecompressedBytes handles payloads without throwing`, async () => { + const payload = new TextEncoder().encode("test payload"); + const out = await streamToArray( + pipejoin([ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }), + glueUnframeStream(), + ]), + ); + strictEqual(out.length, 1); + deepStrictEqual(Array.from(out[0].payload), Array.from(payload)); +}); + +// *** createFrameBuffer: multi-chunk frame (parts.length > 1 -> concat needed in flush) *** // +test(`${variant}: confluentUnframeStream reassembles a frame split into exactly 2 chunks`, async () => { + const framed = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 200 }), + ]), + ); + const full = framed[0]; + const headerChunk = full.slice(0, 5); + const payloadChunk = full.slice(5); + const out = await streamToArray( + pipejoin([ + createReadableStream([headerChunk, payloadChunk]), + confluentUnframeStream(), + ]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 200); + deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld)); +}); + +test(`${variant}: confluentUnframeStream reassembles a frame split into 3+ chunks (multi-part concat)`, async () => { + const payload = new Uint8Array(12); + for (let i = 0; i < 12; i++) payload[i] = i + 1; // none start with 0x00 + const framed = await streamToArray( + pipejoin([ + createReadableStream([payload]), + confluentFrameStream({ schemaId: 201 }), + ]), + ); + const full = framed[0]; // 17 bytes total + const c1 = full.slice(0, 5); // header + const c2 = full.slice(5, 9); // continuation 1 + const c3 = full.slice(9); // continuation 2 + const out = await streamToArray( + pipejoin([createReadableStream([c1, c2, c3]), confluentUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 201); + deepStrictEqual(Array.from(out[0].payload), Array.from(payload)); +}); + +// *** createFrameBuffer: push completes previous multi-part frame when new frame starts *** // +test(`${variant}: confluentUnframeStream emits first multi-part frame when second frame header arrives`, async () => { + const frameA = await streamToArray( + pipejoin([ + createReadableStream([new Uint8Array([0x01, 0x02])]), + confluentFrameStream({ schemaId: 300 }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([new Uint8Array([0x03, 0x04])]), + confluentFrameStream({ schemaId: 301 }), + ]), + ); + const fullA = frameA[0]; + const a1 = fullA.slice(0, 5); // header + const a2 = fullA.slice(5); // continuation + const out = await streamToArray( + pipejoin([ + createReadableStream([a1, a2, frameB[0]]), + confluentUnframeStream(), + ]), + ); + strictEqual(out.length, 2); + strictEqual(out[0].schemaId, 300); + strictEqual(out[1].schemaId, 301); +}); + +// *** createFrameBuffer flush: parts.length > 1 -> concat needed *** // +test(`${variant}: glueUnframeStream flush emits multi-part frame via concat (parts.length > 1)`, async () => { + const bigPayload = new Uint8Array(20); + for (let i = 0; i < 20; i++) bigPayload[i] = i + 1; // none start with 0x03 + const framed = await streamToArray( + pipejoin([ + createReadableStream([bigPayload]), + glueFrameStream({ schemaVersionId: schemaUuid }), + ]), + ); + const full = framed[0]; + const c1 = full.slice(0, 18); // header exactly + const c2 = full.slice(18); // continuation + const out = await streamToArray( + pipejoin([createReadableStream([c1, c2]), glueUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaVersionId, schemaUuid); + deepStrictEqual(Array.from(out[0].payload), Array.from(bigPayload)); +}); + +// *** glueUnframeStream: frame exactly 17 bytes -> too short *** // +test(`${variant}: glueUnframeStream rejects a frame that is exactly 17 bytes (byteLength < 18)`, async () => { + const short = new Uint8Array(17); + short[0] = 0x03; + try { + await pipeline([createReadableStream([short]), glueUnframeStream()]); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("magic byte"), + `Expected magic byte error, got: ${e.message}`, + ); + } +}); + +// *** glueUnframeStream distinctIds tracking *** // +test(`${variant}: glueUnframeStream.result() succeeds when exactly 1 distinct schemaVersionId seen`, async () => { + const frameA = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: schemaUuid }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([new Uint8Array([0x42])]), + glueFrameStream({ schemaVersionId: schemaUuid }), + ]), + ); + const unframe = glueUnframeStream(); + await streamToArray( + pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]), + ); + const result = unframe.result(); + strictEqual(result.value.schemaVersionId, schemaUuid); +}); + +test(`${variant}: glueUnframeStream.result() throws when exactly 2 distinct schemaVersionIds seen`, async () => { + const idA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa00"; + const idB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbb00"; + const frameA = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: idA }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: idB }), + ]), + ); + const unframe = glueUnframeStream(); + await streamToArray( + pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]), + ); + try { + unframe.result(); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("distinct"), + `Expected distinct error, got: ${e.message}`, + ); + ok( + e.message.includes("schemaVersionId"), + `Expected schemaVersionId in error, got: ${e.message}`, + ); + } +}); + +// *** glueUnframeStream.result() resultKey *** // +test(`${variant}: glueUnframeStream.result() uses custom resultKey`, async () => { + const unframe = glueUnframeStream({ resultKey: "myGlueKey" }); + await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: schemaUuid }), + unframe, + ]), + ); + strictEqual(unframe.result().key, "myGlueKey"); +}); + +test(`${variant}: glueUnframeStream.result() defaults to "glueSchemaVersionId"`, async () => { + const unframe = glueUnframeStream(); + await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: schemaUuid }), + unframe, + ]), + ); + strictEqual(unframe.result().key, "glueSchemaVersionId"); +}); + +// *** glueFrameStream.result() resultKey *** // +test(`${variant}: glueFrameStream.result() uses custom resultKey`, async () => { + const stream = glueFrameStream({ + schemaVersionId: schemaUuid, + resultKey: "myGlueFrameKey", + }); + await streamToArray(pipejoin([createReadableStream([helloWorld]), stream])); + strictEqual(stream.result().key, "myGlueFrameKey"); +}); + +test(`${variant}: glueFrameStream.result() defaults to "glueSchemaVersionId"`, async () => { + const stream = glueFrameStream({ schemaVersionId: schemaUuid }); + await streamToArray(pipejoin([createReadableStream([helloWorld]), stream])); + strictEqual(stream.result().key, "glueSchemaVersionId"); + strictEqual(stream.result().value.schemaVersionId, schemaUuid); +}); + +// *** glueUnframeStream: unsupported compression byte error message includes zero-padded hex *** // +test(`${variant}: glueUnframeStream unsupported compression error message includes zero-padded hex byte`, async () => { + // byte 0x09 -> padStart(2,"0") gives "09", not "9" + const frame = new Uint8Array(18 + 1); + frame[0] = 0x03; + frame[1] = 0x09; + try { + await pipeline([createReadableStream([frame]), glueUnframeStream()]); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message.includes("0x09"), + `Expected '0x09' in error message, got: ${e.message}`, + ); + } +}); + +// *** createFrameBuffer startsNewFrame: byteLength >= headerSize not > headerSize *** // +test(`${variant}: confluentUnframeStream accepts a frame chunk of exactly 5 bytes as new-frame start`, async () => { + const emptyPayload = new Uint8Array(0); + const framed = await streamToArray( + pipejoin([ + createReadableStream([emptyPayload]), + confluentFrameStream({ schemaId: 500 }), + ]), + ); + strictEqual(framed[0].byteLength, 5); + const out = await streamToArray( + pipejoin([createReadableStream([framed[0]]), confluentUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 500); + strictEqual(out[0].payload.byteLength, 0); +}); + +test(`${variant}: glueUnframeStream accepts a frame chunk of exactly 18 bytes as new-frame start`, async () => { + const emptyPayload = new Uint8Array(0); + const framed = await streamToArray( + pipejoin([ + createReadableStream([emptyPayload]), + glueFrameStream({ schemaVersionId: schemaUuid }), + ]), + ); + strictEqual(framed[0].byteLength, 18); + const out = await streamToArray( + pipejoin([createReadableStream([framed[0]]), glueUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaVersionId, schemaUuid); + strictEqual(out[0].payload.byteLength, 0); +}); + +// *** flush does nothing on empty stream *** // +test(`${variant}: confluentUnframeStream flush does nothing when stream had no input`, async () => { + const out = await streamToArray( + pipejoin([createReadableStream([]), confluentUnframeStream()]), + ); + strictEqual(out.length, 0); +}); + +test(`${variant}: glueUnframeStream flush does nothing when stream had no input`, async () => { + const out = await streamToArray( + pipejoin([createReadableStream([]), glueUnframeStream()]), + ); + strictEqual(out.length, 0); +}); + +// *** Error messages must be non-empty strings (StringLiteral mutants) *** // +test(`${variant}: confluentFrameStream schemaId validation error message is non-empty`, () => { + try { + confluentFrameStream({ schemaId: undefined }); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message !== "", + `Expected non-empty error message, got: "${e.message}"`, + ); + ok(e.message.length > 10); + } +}); + +test(`${variant}: confluentUnframeStream missing magic byte error message is non-empty`, async () => { + const bogus = new Uint8Array([0x01, 0, 0, 0, 0, 0x68]); + try { + await pipeline([createReadableStream([bogus]), confluentUnframeStream()]); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message !== "", + `Expected non-empty error message, got: "${e.message}"`, + ); + ok(e.message.length > 10); + } +}); + +test(`${variant}: confluentUnframeStream.result() multiple-ids error message is non-empty`, async () => { + const frameA = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 20 }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 21 }), + ]), + ); + const unframe = confluentUnframeStream(); + await streamToArray( + pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]), + ); + try { + unframe.result(); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message !== "", + `Expected non-empty error message, got: "${e.message}"`, + ); + ok(e.message.length > 10); + } +}); + +test(`${variant}: glueUnframeStream.result() multiple-ids error message is non-empty`, async () => { + const idA = "11111111-1111-1111-1111-111111111111"; + const idB = "22222222-2222-2222-2222-222222222222"; + const frameA = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: idA }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: idB }), + ]), + ); + const unframe = glueUnframeStream(); + await streamToArray( + pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]), + ); + try { + unframe.result(); + throw new Error("Should have thrown"); + } catch (e) { + ok( + e.message !== "", + `Expected non-empty error message, got: "${e.message}"`, + ); + ok(e.message.length > 10); + } +}); + +// *** envelope contains both schemaId and payload fields (not empty object) *** // +test(`${variant}: confluentUnframeStream envelope has schemaId and payload fields`, async () => { + const out = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 999 }), + confluentUnframeStream(), + ]), + ); + ok("schemaId" in out[0], "envelope should have schemaId"); + ok("payload" in out[0], "envelope should have payload"); + strictEqual(out[0].schemaId, 999); +}); + +// *** confluentFrameStream value object has schemaId field *** // +test(`${variant}: confluentFrameStream.result() value object contains schemaId`, async () => { + const stream = confluentFrameStream({ schemaId: 42 }); + await streamToArray(pipejoin([createReadableStream([helloWorld]), stream])); + ok("schemaId" in stream.result().value, "value should have schemaId"); + strictEqual(stream.result().value.schemaId, 42); +}); + +// *** glueUnframeStream value object has schemaVersionId and compression *** // +test(`${variant}: glueUnframeStream.result() value object contains schemaVersionId and compression`, async () => { + const unframe = glueUnframeStream(); + await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: schemaUuid }), + unframe, + ]), + ); + ok("schemaVersionId" in unframe.result().value); + ok("compression" in unframe.result().value); + strictEqual(unframe.result().value.schemaVersionId, schemaUuid); +}); + +// *** confluentUnframeStream.result() value object has schemaId field *** // +test(`${variant}: confluentUnframeStream.result() value has schemaId field (not empty object)`, async () => { + const unframe = confluentUnframeStream(); + await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 88 }), + unframe, + ]), + ); + ok("schemaId" in unframe.result().value); + strictEqual(unframe.result().value.schemaId, 88); +}); + +// *** bytesToUuid: padStart with "0" is needed for single-digit hex bytes *** // +test(`${variant}: glueUnframeStream decodes UUID bytes with single-digit hex values (padStart needed)`, async () => { + // "00010203-0405-0607-0809-0a0b0c0d0e0f" has bytes 0x00..0x0f requiring padStart(2,"0") + const uuidWithSmallBytes = "00010203-0405-0607-0809-0a0b0c0d0e0f"; + const out = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: uuidWithSmallBytes }), + glueUnframeStream(), + ]), + ); + strictEqual(out[0].schemaVersionId, uuidWithSmallBytes); +}); + +// *** bytesToUuid loop: i < 16 not <= 16 (exactly 16 bytes needed) *** // +test(`${variant}: glueUnframeStream decodes all 16 UUID bytes correctly (loop bound i < 16)`, async () => { + // All-FF UUID - if loop ran 17 iterations (i <= 16) it would read out-of-bounds bytes. + const allFF = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + const out = await streamToArray( + pipejoin([ + createReadableStream([new Uint8Array([0x42])]), + glueFrameStream({ schemaVersionId: allFF }), + glueUnframeStream(), + ]), + ); + strictEqual(out[0].schemaVersionId, allFF); +}); + +// *** default export contains all 4 functions *** // +test(`${variant}: default export contains all 4 stream factory functions`, async () => { + const mod = await import("@datastream/schema-registry"); + const def = mod.default; + ok( + typeof def.confluentFrameStream === "function", + "confluentFrameStream missing", + ); + ok( + typeof def.confluentUnframeStream === "function", + "confluentUnframeStream missing", + ); + ok(typeof def.glueFrameStream === "function", "glueFrameStream missing"); + ok(typeof def.glueUnframeStream === "function", "glueUnframeStream missing"); +}); + +// *** createFrameBuffer: chunk < headerSize starting with magic is a continuation *** // +test(`${variant}: confluentUnframeStream treats a chunk shorter than 5 bytes starting with 0x00 as continuation`, async () => { + // A 3-byte chunk starting with 0x00 is < headerSize(5), so it is NOT a new frame. + // Instead it is a continuation. Combined with the next bytes, it forms a valid frame. + // Build a valid frame and split it: first 3 bytes (header prefix), then the remaining 8 bytes. + const framed = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 600 }), + ]), + ); + const full = framed[0]; + // first 3 bytes: [0x00, 0x00, 0x00] — starts with magic but only 3 bytes (< 5) + const c1 = full.slice(0, 3); + const c2 = full.slice(3); + const out = await streamToArray( + pipejoin([createReadableStream([c1, c2]), confluentUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 600); + deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld)); +}); + +test(`${variant}: glueUnframeStream treats a chunk shorter than 18 bytes starting with 0x03 as continuation`, async () => { + // A 10-byte chunk starting with 0x03 is < headerSize(18), so it is a continuation. + const framed = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: schemaUuid }), + ]), + ); + const full = framed[0]; + const c1 = full.slice(0, 10); // starts with 0x03 but < 18 bytes + const c2 = full.slice(10); + const out = await streamToArray( + pipejoin([createReadableStream([c1, c2]), glueUnframeStream()]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].schemaVersionId, schemaUuid); + deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld)); +}); + +// *** collectStream: maxOutputSize null/undefined -> no limit applied *** // +test(`${variant}: confluentUnframeStream initial value has schemaId as null before any frames`, async () => { + // Test that result().value.schemaId is null (not undefined) before frames processed. + // With the { schemaId: null } mutation -> {}, value.schemaId would be undefined. + const unframe = confluentUnframeStream(); + // Process one frame so result() does not throw due to distinctIds check + await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 50 }), + unframe, + ]), + ); + strictEqual(unframe.result().value.schemaId, 50); +}); + +test(`${variant}: glueUnframeStream initial value fields present after processing`, async () => { + // Test that result().value has schemaVersionId and compression (not an empty object). + // With the { schemaVersionId: null, compression: null } -> {}, fields would be undefined. + const unframe = glueUnframeStream(); + await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + glueFrameStream({ schemaVersionId: schemaUuid }), + unframe, + ]), + ); + const v = unframe.result().value; + strictEqual(v.schemaVersionId, schemaUuid); + strictEqual(v.compression, "none"); +}); + +// *** collectStream limitName fallback "maxDecompressedBytes" is non-empty string *** // +test(`${variant}: glueUnframeStream limit error message contains non-empty limitName`, async () => { + // When glueUnframeStream calls inflate, limitName="maxDecompressedBytes" is passed. + // With the empty string mutant, the error would say "schema-registry: exceeded" + // instead of "schema-registry: maxDecompressedBytes exceeded". + const payload = new TextEncoder().encode("a".repeat(500)); + try { + await pipeline([ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }), + glueUnframeStream({ maxDecompressedBytes: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + // The error message must contain "maxDecompressedBytes" (not just "exceeded") + ok( + e.message.includes("maxDecompressedBytes"), + `Error should mention maxDecompressedBytes, got: ${e.message}`, + ); + // Verify it's not an empty string for the limit name + const match = e.message.match(/schema-registry: (\S+) exceeded/); + ok( + match !== null, + `Error message format should match 'schema-registry: exceeded', got: ${e.message}`, + ); + strictEqual(match[1], "maxDecompressedBytes"); + } +}); + +// *** confluentUnframeStream initial value has schemaId as null (not undefined) *** // +test(`${variant}: confluentUnframeStream.result() value.schemaId is null before any frames processed`, async () => { + // With { schemaId: null } -> {}, value.schemaId would be undefined, not null. + const unframe = confluentUnframeStream(); + // distinctIds=0 so result() won't throw even before any frames. + const result = unframe.result(); + strictEqual(result.value.schemaId, null); +}); + +// *** glueUnframeStream initial value fields are null (not undefined) *** // +test(`${variant}: glueUnframeStream.result() value fields are null before any frames processed`, async () => { + // With { schemaVersionId: null, compression: null } -> {}, fields would be undefined. + const unframe = glueUnframeStream(); + const result = unframe.result(); + strictEqual(result.value.schemaVersionId, null); + strictEqual(result.value.compression, null); +}); + +// *** Multi-part push: payload bytes must be present in the completed first frame *** // +test(`${variant}: confluentUnframeStream emits correct payload bytes from multi-part first frame`, async () => { + // Build a frame where the payload bytes differ from the header bytes. + // This ensures parts.length===1 ? parts[0] : concat(parts) actually works correctly + // when concat is needed (parts.length > 1 in the push path). + const distinctPayload = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]); + const frameA = await streamToArray( + pipejoin([ + createReadableStream([distinctPayload]), + confluentFrameStream({ schemaId: 700 }), + ]), + ); + const frameB = await streamToArray( + pipejoin([ + createReadableStream([new Uint8Array([0x01])]), + confluentFrameStream({ schemaId: 701 }), + ]), + ); + // Split frameA into header + payload to force parts.length===2 in push. + const a1 = frameA[0].slice(0, 5); // header + const a2 = frameA[0].slice(5); // payload = [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe] + const out = await streamToArray( + pipejoin([ + createReadableStream([a1, a2, frameB[0]]), + confluentUnframeStream(), + ]), + ); + strictEqual(out.length, 2); + strictEqual(out[0].schemaId, 700); + // Must include ALL payload bytes (a2), not just the header (a1). + deepStrictEqual(Array.from(out[0].payload), Array.from(distinctPayload)); + strictEqual(out[1].schemaId, 701); +}); + +// *** createFrameBuffer startsNewFrame requires the LENGTH check, not just magic *** +// A short (< headerSize) continuation chunk that happens to start with the magic +// byte must stay a continuation. Dropping the `byteLength >= headerSize` guard +// (mutant: `true && bytes[0] === magic`) would mis-split it into its own frame. +test(`${variant}: confluentUnframeStream keeps a <5-byte magic-leading continuation attached to the open frame`, async () => { + const framed = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 808 }), + ]), + ); + // A 2-byte trailing continuation that begins with the magic byte 0x00. + const shortCont = new Uint8Array([0x00, 0x99]); + const out = await streamToArray( + pipejoin([ + createReadableStream([framed[0], shortCont]), + confluentUnframeStream(), + ]), + ); + // Correct: ONE frame whose payload is helloWorld + [0x00, 0x99]. + strictEqual(out.length, 1); + strictEqual(out[0].schemaId, 808); + deepStrictEqual(Array.from(out[0].payload), [ + ...Array.from(helloWorld), + 0x00, + 0x99, + ]); +}); + +// *** createFrameBuffer startsNewFrame uses >= headerSize, not > headerSize *** +// A second frame that is EXACTLY headerSize bytes (empty payload) must be +// recognised as a new frame. Mutant `> headerSize` would fold it into the +// previous frame as a continuation. +test(`${variant}: confluentUnframeStream treats an exactly-5-byte second frame as a new frame`, async () => { + const frameA = await streamToArray( + pipejoin([ + createReadableStream([helloWorld]), + confluentFrameStream({ schemaId: 811 }), + ]), + ); + // Second frame: empty payload => exactly 5 bytes, distinct schemaId. + const frameB = await streamToArray( + pipejoin([ + createReadableStream([new Uint8Array(0)]), + confluentFrameStream({ schemaId: 812 }), + ]), + ); + strictEqual(frameB[0].byteLength, 5); + const out = await streamToArray( + pipejoin([ + createReadableStream([frameA[0], frameB[0]]), + confluentUnframeStream(), + ]), + ); + // Correct: TWO distinct envelopes. Mutant (`>`) would merge into one. + strictEqual(out.length, 2); + strictEqual(out[0].schemaId, 811); + deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld)); + strictEqual(out[1].schemaId, 812); + strictEqual(out[1].payload.byteLength, 0); +}); + +// *** collectStream maxOutputSize guard: `!= null` must gate the size check *** +// Passing maxDecompressedBytes: null explicitly disables the ceiling. The guard +// `maxOutputSize != null && total > maxOutputSize` must short-circuit to false. +// Mutant `true && total > maxOutputSize` becomes `total > null` => `total > 0`, +// which would throw for any non-empty decompressed output. +test(`${variant}: glueUnframeStream with maxDecompressedBytes: null imposes no ceiling`, async () => { + const payload = new TextEncoder().encode("a".repeat(2048)); + const out = await streamToArray( + pipejoin([ + createReadableStream([payload]), + glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }), + glueUnframeStream({ maxDecompressedBytes: null }), + ]), + ); + strictEqual(out.length, 1); + strictEqual(out[0].compression, "zlib"); + deepStrictEqual(Array.from(out[0].payload), Array.from(payload)); +}); diff --git a/packages/schema-registry/index.tst.ts b/packages/schema-registry/index.tst.ts new file mode 100644 index 0000000..b7b2e6f --- /dev/null +++ b/packages/schema-registry/index.tst.ts @@ -0,0 +1,73 @@ +/// +/// +import type { + ConfluentEnvelope, + GlueEnvelope, +} from "@datastream/schema-registry"; +import { + confluentFrameStream, + confluentUnframeStream, + glueFrameStream, + glueUnframeStream, +} from "@datastream/schema-registry"; +import { describe, expect, test } from "tstyche"; + +describe("ConfluentEnvelope", () => { + test("has schemaId and payload", () => { + expect().type.toBeAssignableTo<{ + schemaId: number; + payload: Uint8Array; + }>(); + }); +}); + +describe("GlueEnvelope", () => { + test("has schemaVersionId, compression, and payload", () => { + expect().type.toBeAssignableTo<{ + schemaVersionId: string; + compression: "none" | "zlib"; + payload: Uint8Array; + }>(); + }); +}); + +describe("confluentFrameStream", () => { + test("requires schemaId", () => { + expect( + confluentFrameStream({ schemaId: 1 }), + ).type.not.toBeAssignableTo(); + }); + + test("exposes result", () => { + const stream = confluentFrameStream({ schemaId: 1 }); + expect(stream.result).type.not.toBeAssignableTo(); + }); +}); + +describe("confluentUnframeStream", () => { + test("accepts no options", () => { + expect(confluentUnframeStream()).type.not.toBeAssignableTo(); + }); +}); + +describe("glueFrameStream", () => { + test("requires schemaVersionId", () => { + expect( + glueFrameStream({ schemaVersionId: "abc" }), + ).type.not.toBeAssignableTo(); + }); + + test("accepts compression", () => { + expect( + glueFrameStream({ schemaVersionId: "abc", compression: "zlib" }), + ).type.not.toBeAssignableTo(); + }); +}); + +describe("glueUnframeStream", () => { + test("accepts maxDecompressedBytes", () => { + expect( + glueUnframeStream({ maxDecompressedBytes: 1024 }), + ).type.not.toBeAssignableTo(); + }); +}); diff --git a/packages/schema-registry/package.json b/packages/schema-registry/package.json new file mode 100644 index 0000000..cd2e0d9 --- /dev/null +++ b/packages/schema-registry/package.json @@ -0,0 +1,71 @@ +{ + "name": "@datastream/schema-registry", + "version": "0.6.1", + "description": "Schema-Registry wire-format framing transform streams (Confluent and AWS Glue)", + "type": "module", + "engines": { + "node": ">=24" + }, + "engineStrict": true, + "publishConfig": { + "access": "public" + }, + "main": "./index.node.mjs", + "module": "./index.web.mjs", + "exports": { + ".": { + "node": { + "import": { + "types": "./index.d.ts", + "default": "./index.node.mjs" + } + }, + "import": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "./webstream": { + "types": "./index.d.ts", + "default": "./index.web.mjs" + } + }, + "types": "index.d.ts", + "files": [ + "*.mjs", + "*.map", + "*.d.ts" + ], + "scripts": { + "test": "npm run test:unit", + "test:unit": "node --test", + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" + }, + "license": "MIT", + "keywords": [ + "schema-registry", + "confluent", + "glue", + "avro", + "protobuf", + "Web Stream API", + "Node Stream API" + ], + "author": { + "name": "datastream contributors", + "url": "https://github.com/willfarrell/datastream/graphs/contributors" + }, + "repository": { + "type": "git", + "url": "github:willfarrell/datastream", + "directory": "packages/schema-registry" + }, + "bugs": { + "url": "https://github.com/willfarrell/datastream/issues" + }, + "homepage": "https://datastream.js.org", + "dependencies": { + "@datastream/core": "0.6.1" + } +} diff --git a/packages/string/README.md b/packages/string/README.md index 9f86585..e630ed7 100644 --- a/packages/string/README.md +++ b/packages/string/README.md @@ -1,6 +1,6 @@

<datastream> `string`

- datastream logo + datastream logo

String manipulation streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/string/index.d.ts b/packages/string/index.d.ts index 0be1c1f..2ec80a4 100644 --- a/packages/string/index.d.ts +++ b/packages/string/index.d.ts @@ -32,24 +32,30 @@ export function stringCountStream( result: () => StreamResult; }; -export function stringMinimumFirstChunkSize( +export function stringMinimumFirstChunkSizeStream( options?: { chunkSize?: number; }, streamOptions?: StreamOptions, ): DatastreamTransform; +/** @deprecated Use stringMinimumFirstChunkSizeStream */ +export const stringMinimumFirstChunkSize: typeof stringMinimumFirstChunkSizeStream; -export function stringMinimumChunkSize( +export function stringMinimumChunkSizeStream( options?: { chunkSize?: number; }, streamOptions?: StreamOptions, ): DatastreamTransform; +/** @deprecated Use stringMinimumChunkSizeStream */ +export const stringMinimumChunkSize: typeof stringMinimumChunkSizeStream; -export function stringSkipConsecutiveDuplicates( +export function stringSkipConsecutiveDuplicatesStream( options?: Record, streamOptions?: StreamOptions, ): DatastreamTransform; +/** @deprecated Use stringSkipConsecutiveDuplicatesStream */ +export const stringSkipConsecutiveDuplicates: typeof stringSkipConsecutiveDuplicatesStream; export function stringReplaceStream( options: { @@ -72,7 +78,9 @@ declare const _default: { readableStream: typeof stringReadableStream; lengthStream: typeof stringLengthStream; countStream: typeof stringCountStream; - skipConsecutiveDuplicates: typeof stringSkipConsecutiveDuplicates; + minimumFirstChunkSize: typeof stringMinimumFirstChunkSizeStream; + minimumChunkSize: typeof stringMinimumChunkSizeStream; + skipConsecutiveDuplicates: typeof stringSkipConsecutiveDuplicatesStream; replaceStream: typeof stringReplaceStream; splitStream: typeof stringSplitStream; }; diff --git a/packages/string/index.js b/packages/string/index.js index 410d0d6..04f8cb2 100644 --- a/packages/string/index.js +++ b/packages/string/index.js @@ -23,16 +23,22 @@ export const stringCountStream = ( { substr, resultKey } = {}, streamOptions = {}, ) => { + if (typeof substr !== "string" || substr.length === 0) { + throw new Error("stringCountStream requires a non-empty substr"); + } let value = 0; + let carry = ""; const passThrough = (chunk) => { + const combined = carry + chunk; let cursor = -1; - while (cursor < chunk.length) { - cursor = chunk.indexOf(substr, cursor + 1); + while (true) { + cursor = combined.indexOf(substr, cursor + 1); if (cursor === -1) { break; } value += 1; } + carry = combined.slice(-(substr.length - 1)); }; const stream = createPassThroughStream(passThrough, streamOptions); stream.result = () => ({ key: resultKey ?? "count", value }); @@ -54,7 +60,6 @@ export const stringMinimumFirstChunkSize = ( buffer += chunk; if (buffer.length >= chunkSize) { enqueue(buffer); - buffer = ""; done = true; } }; @@ -141,6 +146,9 @@ export const stringSplitStream = (options, streamOptions = {}) => { separator, maxBufferSize = 16_777_216, // 16MB } = options; + if (typeof separator !== "string" || separator.length === 0) { + throw new Error("stringSplitStream requires a non-empty separator"); + } let previousChunk = ""; const transform = (chunk, enqueue) => { chunk = previousChunk + chunk; diff --git a/packages/string/index.test.js b/packages/string/index.test.js index 748d904..0693d2b 100644 --- a/packages/string/index.test.js +++ b/packages/string/index.test.js @@ -305,6 +305,35 @@ test(`${variant}: stringSplitStream should throw when buffer exceeds maxBufferSi } }); +// *** stringSplitStream empty separator guard *** // +test(`${variant}: stringSplitStream should throw on empty separator at construction`, (_t) => { + let threw = false; + try { + stringSplitStream({ separator: "" }); + } catch (e) { + threw = true; + ok( + e.message.includes("non-empty separator"), + `expected non-empty separator in error, got: ${e.message}`, + ); + } + ok(threw, "stringSplitStream({ separator: '' }) should throw"); +}); + +// *** stringCountStream cross-chunk boundary *** // +test(`${variant}: stringCountStream should count occurrences spanning chunk boundaries`, async (_t) => { + const input = ["hel", "lo"]; + const streams = [ + createReadableStream(input), + stringCountStream({ substr: "hello" }), + ]; + + await pipeline(streams); + const { value } = streams[1].result(); + + strictEqual(value, 1); +}); + // *** default export *** // test(`${variant}: default export should include all stream functions`, (_t) => { deepStrictEqual(Object.keys(stringDefault).sort(), [ @@ -316,3 +345,307 @@ test(`${variant}: default export should include all stream functions`, (_t) => { "splitStream", ]); }); + +// *** stringCountStream guard: typeof check *** // +test(`${variant}: stringCountStream should throw when substr is not a string (number)`, (_t) => { + let threw = false; + try { + stringCountStream({ substr: 42 }); + } catch (e) { + threw = true; + strictEqual(e.message, "stringCountStream requires a non-empty substr"); + } + ok(threw, "stringCountStream({ substr: 42 }) should throw"); +}); + +test(`${variant}: stringCountStream should throw when substr is not a string (undefined)`, (_t) => { + let threw = false; + try { + stringCountStream({}); + } catch (e) { + threw = true; + strictEqual(e.message, "stringCountStream requires a non-empty substr"); + } + ok(threw, "stringCountStream({}) should throw"); +}); + +test(`${variant}: stringCountStream should throw on empty substr`, (_t) => { + let threw = false; + try { + stringCountStream({ substr: "" }); + } catch (e) { + threw = true; + strictEqual(e.message, "stringCountStream requires a non-empty substr"); + } + ok(threw, "stringCountStream({ substr: '' }) should throw"); +}); + +// *** stringCountStream: cursor boundary (< vs <=) *** // +test(`${variant}: stringCountStream should count match at the very end of combined string`, async (_t) => { + // substr "ab" with combined exactly ending in "ab" — exercises cursor at length boundary + const input = ["xab"]; + const streams = [ + createReadableStream(input), + stringCountStream({ substr: "ab" }), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value, 1); +}); + +// *** stringCountStream: carry arithmetic (substr.length - 1 vs + 1) *** // +test(`${variant}: stringCountStream should NOT double-count across chunk boundary when carry is exact`, async (_t) => { + // substr = "ab", chunk1 = "a", chunk2 = "b" => carry="a", combined="ab" => 1 match + // if carry used -(length+1) it would incorrectly carry "xa" or extra chars, changing count + const input = ["a", "b", "ab"]; + const streams = [ + createReadableStream(input), + stringCountStream({ substr: "ab" }), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value, 2); +}); + +test(`${variant}: stringCountStream carry should not include extra characters that cause false positives`, async (_t) => { + // substr = "ab" (length 2), carry should be 1 char (length-1) + // chunk1="xxa", chunk2="b" => combined="xxab", carry after chunk1 = "a" (1 char) + // if carry were 2 chars ("xa"), combined in chunk2 = "xab", still finds "ab" once + // but with "aab" boundary: chunk1="aab", chunk2="c" => should count 1 not 2 + const input = ["aab", "c"]; + const streams = [ + createReadableStream(input), + stringCountStream({ substr: "ab" }), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value, 1); +}); + +test(`${variant}: stringCountStream carry boundary with 3-char substr`, async (_t) => { + // substr = "abc" (length 3), carry should be 2 chars (length-1=2) + // chunk1="xab", chunk2="cd" => carry="ab" (2 chars), combined="abcd", finds "abc" once total + // if carry were 3 chars "xab", combined="xabcd", still finds "abc" once - same + // Better: chunk1="abc" chunk2="abc" => 2 matches; carry from chunk1 = "bc" (2 chars) + // combined in chunk2 = "bcabc" => finds "abc" at pos 2 => total = 2 + const input = ["abc", "abc"]; + const streams = [ + createReadableStream(input), + stringCountStream({ substr: "abc" }), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value, 2); +}); + +// *** stringMinimumFirstChunkSize: buffer reset after emit *** // +test(`${variant}: stringMinimumFirstChunkSize should output exact chunk size when met and not carry leftovers`, async (_t) => { + // chunkSize=4, input ["ab","cd","ef"] + // After emitting "abcd", buffer must be "" so next chunk "ef" is passed through as-is + // If buffer="" mutant used "Stryker was here!", subsequent chunk would be wrong + const input = ["ab", "cd", "ef"]; + const streams = [ + createReadableStream(input), + stringMinimumFirstChunkSize({ chunkSize: 4 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["abcd", "ef"]); +}); + +test(`${variant}: stringMinimumFirstChunkSize should not emit in flush when done=true and buffer empty`, async (_t) => { + // When chunkSize is met exactly, done=true and buffer="" => flush should emit nothing + const input = ["abcd"]; + const streams = [ + createReadableStream(input), + stringMinimumFirstChunkSize({ chunkSize: 4 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // "abcd" meets chunkSize=4 exactly, emitted in transform; flush emits nothing (buffer empty, done=true) + deepStrictEqual(output, ["abcd"]); +}); + +test(`${variant}: stringMinimumFirstChunkSize should not flush empty buffer when done=false`, async (_t) => { + // Input is empty => done=false, buffer="" => flush should NOT emit empty string + const input = [""]; + const streams = [ + createReadableStream(input), + stringMinimumFirstChunkSize({ chunkSize: 4 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // buffer is "" — flush condition `buffer.length > 0` must prevent enqueue + deepStrictEqual(output, []); +}); + +// *** stringReplaceStream: RegExp guard *** // +test(`${variant}: stringReplaceStream should throw on RegExp without g or y flag`, (_t) => { + let threw = false; + try { + stringReplaceStream({ pattern: /hello/, replacement: "hi" }); + } catch (e) { + threw = true; + strictEqual( + e.message, + "RegExp pattern must include the global (g) or sticky (y) flag", + ); + } + ok(threw, "stringReplaceStream with /hello/ (no flags) should throw"); +}); + +test(`${variant}: stringReplaceStream should NOT throw on RegExp with g flag`, async (_t) => { + const input = ["hello"]; + const streams = [ + createReadableStream(input), + stringReplaceStream({ pattern: /hello/g, replacement: "hi" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["", "hi"]); +}); + +test(`${variant}: stringReplaceStream should NOT throw on RegExp with y flag`, async (_t) => { + const input = ["hello"]; + const streams = [ + createReadableStream(input), + stringReplaceStream({ pattern: /hello/y, replacement: "hi" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["", "hi"]); +}); + +test(`${variant}: stringReplaceStream should NOT throw on RegExp with both g and y flags`, async (_t) => { + const input = ["hello"]; + const streams = [ + createReadableStream(input), + stringReplaceStream({ pattern: /hello/gy, replacement: "hi" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["", "hi"]); +}); + +// *** stringReplaceStream: useReplaceAll (string vs RegExp path) *** // +test(`${variant}: stringReplaceStream should replace ALL occurrences with string pattern (replaceAll path)`, async (_t) => { + // String pattern uses replaceAll, regex uses replace + // With string "a", input "aaa" => all 3 replaced + const input = ["aaa"]; + const streams = [ + createReadableStream(input), + stringReplaceStream({ pattern: "a", replacement: "b" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["", "bbb"]); +}); + +test(`${variant}: stringReplaceStream with regex /a/g should replace all occurrences (replace path with global)`, async (_t) => { + const input = ["aaa"]; + const streams = [ + createReadableStream(input), + stringReplaceStream({ pattern: /a/g, replacement: "b" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["", "bbb"]); +}); + +// *** stringReplaceStream: maxBufferSize exact boundary (> vs >=) *** // +test(`${variant}: stringReplaceStream should NOT throw when buffer equals maxBufferSize exactly`, async (_t) => { + // previousChunk.length === maxBufferSize should NOT throw (only > maxBufferSize should) + // Use pattern that never matches so buffer grows; first chunk "aaaa" (4 chars) with maxBufferSize=4 + // After first chunk: combined="aaaa", enqueue substring(0,0)="", previousChunk="aaaa" (4 chars) + // 4 > 4 is false => should NOT throw + const input = ["aaaa"]; + const streams = [ + createReadableStream(input), + stringReplaceStream({ + pattern: "zzz", + replacement: "yyy", + maxBufferSize: 4, + }), + ]; + // Should not throw for 4-char buffer with maxBufferSize=4 + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["", "aaaa"]); +}); + +test(`${variant}: stringReplaceStream should throw when buffer exceeds maxBufferSize by 1`, async (_t) => { + // previousChunk.length = 5 > maxBufferSize = 4 => throws + const input = ["aaaaa"]; + const streams = [ + createReadableStream(input), + stringReplaceStream({ + pattern: "zzz", + replacement: "yyy", + maxBufferSize: 4, + }), + ]; + try { + await pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// *** stringSplitStream: typeof separator guard *** // +test(`${variant}: stringSplitStream should throw when separator is not a string (number)`, (_t) => { + let threw = false; + try { + stringSplitStream({ separator: 42 }); + } catch (e) { + threw = true; + ok( + e.message.includes("non-empty separator"), + `expected non-empty separator error, got: ${e.message}`, + ); + } + ok(threw, "stringSplitStream({ separator: 42 }) should throw"); +}); + +test(`${variant}: stringSplitStream should throw when separator is undefined`, (_t) => { + let threw = false; + try { + stringSplitStream({}); + } catch (e) { + threw = true; + ok( + e.message.includes("non-empty separator"), + `expected non-empty separator error, got: ${e.message}`, + ); + } + ok(threw, "stringSplitStream({}) should throw"); +}); + +// *** stringSplitStream: maxBufferSize exact boundary (> vs >=) *** // +test(`${variant}: stringSplitStream should NOT throw when buffer equals maxBufferSize exactly`, async (_t) => { + // separator "zzz" won't be found; chunk "aaaa" (4 chars), maxBufferSize=4 + // previousChunk.length=4, 4 > 4 is false => should NOT throw + const input = ["aaaa"]; + const streams = [ + createReadableStream(input), + stringSplitStream({ separator: "zzz", maxBufferSize: 4 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["aaaa"]); +}); + +test(`${variant}: stringSplitStream should throw when buffer exceeds maxBufferSize by 1`, async (_t) => { + // chunk "aaaaa" (5 chars), maxBufferSize=4; 5 > 4 => throws + const input = ["aaaaa"]; + const streams = [ + createReadableStream(input), + stringSplitStream({ separator: "zzz", maxBufferSize: 4 }), + ]; + try { + await pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); diff --git a/packages/string/package.json b/packages/string/package.json index e8fc19b..bfc0929 100644 --- a/packages/string/package.json +++ b/packages/string/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/string", - "version": "0.5.0", + "version": "0.6.1", "description": "String transform streams for splitting, replacing, counting, and deduplication", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,6 +61,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/validate/README.md b/packages/validate/README.md index a82d4f1..5211f4e 100644 --- a/packages/validate/README.md +++ b/packages/validate/README.md @@ -1,6 +1,6 @@

<datastream> `validate`

- datastream logo + datastream logo

JSON schema validation streams.

GitHub Actions unit test status @@ -18,10 +18,10 @@
Open Source Security Foundation (OpenSSF) Scorecard SLSA 3 - + Checked with Biome Conventional Commits - + code coverage

You can read the documentation at: https://datastream.js.org

diff --git a/packages/validate/index.js b/packages/validate/index.js index 56b65fb..5588449 100644 --- a/packages/validate/index.js +++ b/packages/validate/index.js @@ -26,9 +26,16 @@ export const validateStream = ( allowCoerceTypes, resultKey, maxErrorRows = Infinity, - }, + maxErrorKeys = 1000, + } = {}, streamOptions = {}, ) => { + if (!schema) { + throw new Error( + "validateStream requires a schema (JSON Schema object or compiled AJV function)", + ); + } + idxStart ??= 0; if (typeof schema !== "function") { @@ -36,18 +43,48 @@ export const validateStream = ( } const value = {}; // aka errors + let valueCount = 0; // track distinct id count without Object.keys() per error let idx = idxStart - 1; const transform = (chunk, enqueue) => { idx += 1; - const data = - allowCoerceTypes === false ? structuredClone(chunk) : undefined; - const chunkValid = schema(chunk); + + let emitChunk; + let validateTarget; + + if (allowCoerceTypes === false) { + // Validate on a clone so AJV coercion does not mutate the original. + // Emit the original (uncoerced) chunk. + try { + validateTarget = structuredClone(chunk); + } catch { + // Non-cloneable: fall back to validating the original (best-effort) + validateTarget = chunk; + } + emitChunk = chunk; + } else { + // Clone before validation so AJV coercion does not mutate caller's object. + // Emit the coerced clone. + try { + validateTarget = structuredClone(chunk); + } catch { + // Non-cloneable: validate the original (caller's object may be mutated) + validateTarget = chunk; + } + emitChunk = validateTarget; + } + + const chunkValid = schema(validateTarget); if (!chunkValid) { for (const error of schema.errors) { const { id, keys, message } = processError(error); if (!value[id]) { + // Stop creating new entries once maxErrorKeys cap is reached + if (valueCount >= maxErrorKeys) { + continue; + } value[id] = { id, keys, message, idx: [] }; + valueCount += 1; } if (value[id].idx.length < maxErrorRows) { value[id].idx.push(idx); @@ -55,7 +92,7 @@ export const validateStream = ( } } if (chunkValid || onErrorEnqueue) { - enqueue(data ?? chunk); + enqueue(emitChunk); } }; const stream = createTransformStream(transform, streamOptions); @@ -76,7 +113,8 @@ const processError = (error) => { }); keys = [...new Set(keys.sort())]; } else { - keys.push(makeKeys(error)); + const key = makeKeys(error); + if (key) keys.push(key); } if (!error.instancePath && keys.length) { id += `/${keys.join("|")}`; diff --git a/packages/validate/index.test.js b/packages/validate/index.test.js index 5f3eefc..7d399b7 100644 --- a/packages/validate/index.test.js +++ b/packages/validate/index.test.js @@ -368,8 +368,8 @@ test(`${variant}: validateStream should handle root-level type error`, async (_t ok(Object.keys(result.validate).length > 0); const errorKey = Object.keys(result.validate)[0]; - // makeKeys returns "" for root-level errors (empty instancePath) - deepStrictEqual(result.validate[errorKey].keys, [""]); + // root-level errors produce an empty makeKeys result; empty keys are filtered out + deepStrictEqual(result.validate[errorKey].keys, []); }); test(`${variant}: validateStream should handle errorMessage with root-level error`, async (_t) => { @@ -446,7 +446,330 @@ test(`${variant}: validateStream should cap error indices with maxErrorRows`, as ok(result.validate[errorKey].idx.length <= 10); }); +// *** maxErrorKeys *** // +test(`${variant}: validateStream should cap distinct error ids with maxErrorKeys`, async (_t) => { + // Feed 500 rows, each with a unique extra property name → would produce 500 distinct ids + const schema = { + type: "object", + additionalProperties: false, + properties: { a: { type: "number" } }, + }; + const input = Array.from({ length: 500 }, (_, i) => ({ + a: 1, + [`extra_${i}`]: i, + })); + const streams = [ + createReadableStream(input), + validateStream({ schema, maxErrorKeys: 50 }), + ]; + const result = await pipeline(streams); + + // Should never exceed maxErrorKeys distinct entries + ok(Object.keys(result.validate).length <= 50); +}); + +test(`${variant}: validateStream should default maxErrorKeys to 1000`, async (_t) => { + // 1500 rows each with a unique extra property → default cap of 1000 should kick in + const schema = { + type: "object", + additionalProperties: false, + properties: { a: { type: "number" } }, + }; + const input = Array.from({ length: 1500 }, (_, i) => ({ + a: 1, + [`extra_${i}`]: i, + })); + const streams = [createReadableStream(input), validateStream({ schema })]; + const result = await pipeline(streams); + + ok(Object.keys(result.validate).length <= 1000); +}); + +// *** allowCoerceTypes:false with non-cloneable chunks *** // +test(`${variant}: validateStream should not throw when allowCoerceTypes:false and chunk contains non-cloneable value`, async (_t) => { + const schema = { + type: "object", + properties: { a: { type: "number" } }, + required: ["a"], + }; + // Chunk with a function (not structuredClone-able) + const input = [{ a: 1, fn: () => {} }]; + + const streams = [ + createReadableStream(input), + validateStream({ schema, allowCoerceTypes: false }), + ]; + // Should not throw — should fall back to original chunk + const result = await pipeline(streams); + ok(result, "pipeline should complete without error"); +}); + +// *** root-level errors: no trailing-slash id, no empty-string key *** // +test(`${variant}: validateStream root-level error should not have trailing-slash id or empty-string key`, async (_t) => { + const schema = { type: "object" }; + const input = ["not-an-object"]; + const streams = [createReadableStream(input), validateStream({ schema })]; + const result = await pipeline(streams); + + ok(Object.keys(result.validate).length > 0); + for (const [id, entry] of Object.entries(result.validate)) { + // id must not end with '/' + strictEqual(id.endsWith("/"), false, `id '${id}' must not end with '/'`); + // keys must not include '' + strictEqual( + entry.keys.includes(""), + false, + `keys must not include empty string`, + ); + } +}); + +// *** default path should not mutate caller's input *** // +test(`${variant}: validateStream should emit a clone so caller input is not mutated`, async (_t) => { + // coerceTypes:true would mutate {a:'1'} → {a:1} on the original object + const original = { a: "1" }; + const input = [original]; + const schema = { + type: "object", + properties: { a: { type: "number" } }, + required: ["a"], + }; + + const streams = [createReadableStream(input), validateStream({ schema })]; + const output = await streamToArray(pipejoin(streams)); + + // The emitted chunk should be coerced + strictEqual(output[0].a, 1); + // But the original reference must NOT be mutated + strictEqual( + original.a, + "1", + "caller's original object must not be mutated in place", + ); +}); + +// *** no-schema branded error *** // +test(`${variant}: validateStream should throw a branded error when schema is missing`, (_t) => { + try { + validateStream({}); + ok(false, "should have thrown"); + } catch (err) { + ok( + err.message.includes("validateStream") && err.message.includes("schema"), + `expected branded error mentioning validateStream and schema, got: ${err.message}`, + ); + } +}); + // *** default export *** // test(`${variant}: default export should be validateStream`, (_t) => { strictEqual(validateDefault, validateStream); }); + +// *** transpileSchema strict:true mode *** // +test(`${variant}: transpileSchema should use strict:true by default and reject schemas with unknown keywords`, (_t) => { + // In strict mode, AJV throws on unknown keywords in schemas + let threw = false; + try { + transpileSchema({ type: "object", unknownKeyword: true }); + } catch { + threw = true; + } + // strict:false would silently accept unknown keywords; strict:true throws + strictEqual( + threw, + true, + "transpileSchema should throw on unknown keywords in strict mode", + ); +}); + +// *** transpileSchema useDefaults:"empty" *** // +test(`${variant}: transpileSchema should apply default values for empty strings (useDefaults:"empty")`, async (_t) => { + // useDefaults:"empty" fills defaults even when value is an empty string + const schema = { + type: "object", + properties: { + name: { type: "string", default: "fallback" }, + }, + }; + const input = [{ name: "" }]; + const streams = [createReadableStream(input), validateStream({ schema })]; + const output = await streamToArray(pipejoin(streams)); + // useDefaults:"empty" should replace empty string with the default + deepStrictEqual(output[0].name, "fallback"); +}); + +// *** catch block fallback: non-cloneable in allowCoerceTypes:false path *** // +test(`${variant}: validateStream allowCoerceTypes:false with non-cloneable chunk emits original chunk`, async (_t) => { + const schema = { + type: "object", + properties: { a: { type: "string" } }, + }; + const original = { a: "hello", fn: () => {} }; + const input = [original]; + const streams = [ + createReadableStream(input), + validateStream({ schema, allowCoerceTypes: false }), + ]; + const output = await streamToArray(pipejoin(streams)); + // Should emit the original object reference (the chunk itself, not a clone) + strictEqual( + output[0], + original, + "non-cloneable chunk should emit original reference when allowCoerceTypes:false", + ); + strictEqual(output[0].a, "hello"); +}); + +// *** catch block fallback: non-cloneable in default (coerce) path *** // +test(`${variant}: validateStream default coerce path with non-cloneable chunk still emits chunk`, async (_t) => { + const schema = { + type: "object", + properties: { a: { type: "string" } }, + }; + const original = { a: "hello", fn: () => {} }; + const input = [original]; + const streams = [createReadableStream(input), validateStream({ schema })]; + const output = await streamToArray(pipejoin(streams)); + // Should emit something (the original chunk when clone fails) + strictEqual(output[0].a, "hello"); +}); + +// *** if (!value[id]) guard: same error should accumulate idx, not reset *** // +test(`${variant}: validateStream should accumulate idx for repeated same-key errors`, async (_t) => { + // Multiple rows with same type error → same id → idx should accumulate + const input = [{ a: "bad" }, { a: "bad" }, { a: "bad" }]; + const schema = { + type: "object", + properties: { a: { type: "number" } }, + }; + const streams = [createReadableStream(input), validateStream({ schema })]; + const result = await pipeline(streams); + const errorKey = Object.keys(result.validate)[0]; + // All three rows should appear in idx + deepStrictEqual(result.validate[errorKey].idx, [0, 1, 2]); +}); + +// *** if (chunkValid || onErrorEnqueue): invalid items NOT enqueued by default *** // +test(`${variant}: validateStream should not enqueue invalid items when onErrorEnqueue is not set`, async (_t) => { + const input = [ + { a: "valid_number_string" }, + { a: "definitely-not-a-number" }, + ]; + const schema = { + type: "object", + properties: { a: { type: "number" } }, + }; + const streams = [createReadableStream(input), validateStream({ schema })]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // Only valid (coerced) items should be emitted — "definitely-not-a-number" can't coerce + // "valid_number_string" also can't coerce; both invalid → output empty + deepStrictEqual(output.length, 0); +}); + +// *** errorMessage keyword: branch must populate keys from sub-errors *** // +test(`${variant}: validateStream errorMessage should collect keys from sub-errors`, async (_t) => { + const schema = { + type: "object", + properties: { + age: { type: "number" }, + }, + required: ["age"], + errorMessage: { + properties: { + age: "Age must be a number", + }, + }, + }; + const input = [{ age: "not-a-number" }]; + const streams = [createReadableStream(input), validateStream({ schema })]; + const result = await pipeline(streams); + + ok(Object.keys(result.validate).length > 0); + const errorEntry = result.validate["#/errorMessage"]; + ok(errorEntry, "errorMessage error entry should exist"); + // Keys should be populated from the sub-error (age) + ok( + errorEntry.keys.includes("age"), + `keys should include 'age', got: ${JSON.stringify(errorEntry.keys)}`, + ); + strictEqual(errorEntry.message, "Age must be a number"); +}); + +// *** errorMessage keys sorted and deduped *** // +test(`${variant}: validateStream errorMessage should sort keys alphabetically (z_field before a_field in required → sorted as a_field|z_field)`, async (_t) => { + // Required array has z_field FIRST so AJV returns sub-errors as [z_field, a_field]. + // The sort() must reorder them to [a_field, z_field] for the id to be correct. + const schema = { + type: "object", + properties: { + z_field: { type: "string" }, + a_field: { type: "string" }, + }, + required: ["z_field", "a_field"], + errorMessage: { + required: "Both fields are required", + }, + }; + const input = [{}]; + const streams = [createReadableStream(input), validateStream({ schema })]; + const result = await pipeline(streams); + + ok(Object.keys(result.validate).length > 0); + // Without sort: id would be "#/errorMessage/z_field|a_field" + // With sort: id must be "#/errorMessage/a_field|z_field" + const errorEntry = result.validate["#/errorMessage/a_field|z_field"]; + ok( + errorEntry, + "error entry id '#/errorMessage/a_field|z_field' must exist (keys sorted)", + ); + deepStrictEqual(errorEntry.keys, ["a_field", "z_field"]); + strictEqual(errorEntry.message, "Both fields are required"); +}); + +// *** errorMessage: falsy makeKeys values should NOT be pushed into keys *** // +test(`${variant}: validateStream errorMessage sub-errors with empty instancePath produce no empty string keys`, async (_t) => { + const schema = { + type: "object", + errorMessage: "Must be an object", + }; + const input = ["not-an-object"]; + const streams = [createReadableStream(input), validateStream({ schema })]; + const result = await pipeline(streams); + + ok(Object.keys(result.validate).length > 0); + for (const entry of Object.values(result.validate)) { + strictEqual( + entry.keys.includes(""), + false, + "keys must not include empty string from errorMessage sub-errors", + ); + } +}); + +// *** id suffix: multiple keys joined with "|" not "" *** // +test(`${variant}: validateStream error id should join multiple keys with pipe separator`, async (_t) => { + // Use required errorMessage with two required fields. Both missing → two sub-errors + // → keys = ["a_field", "b_field"] → id = "#/errorMessage/a_field|b_field" + const schema = { + type: "object", + properties: { + b_field: { type: "string" }, + a_field: { type: "string" }, + }, + required: ["a_field", "b_field"], + errorMessage: { + required: "Fields a_field and b_field are required", + }, + }; + const input = [{}]; + const streams = [createReadableStream(input), validateStream({ schema })]; + const result = await pipeline(streams); + + // The entry id MUST be "#/errorMessage/a_field|b_field" — pipe not empty-string join + const errorEntry = result.validate["#/errorMessage/a_field|b_field"]; + ok(errorEntry, "error entry id '#/errorMessage/a_field|b_field' must exist"); + deepStrictEqual(errorEntry.keys, ["a_field", "b_field"]); + strictEqual(errorEntry.message, "Fields a_field and b_field are required"); +}); diff --git a/packages/validate/package.json b/packages/validate/package.json index b91380a..a6dd099 100644 --- a/packages/validate/package.json +++ b/packages/validate/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/validate", - "version": "0.5.0", + "version": "0.6.1", "description": "JSON Schema validation transform streams using Ajv", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,7 +61,7 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0", + "@datastream/core": "0.6.1", "ajv-cmd": "0.13.3" } } diff --git a/stryker.config.mjs b/stryker.config.mjs new file mode 100644 index 0000000..d907e1a --- /dev/null +++ b/stryker.config.mjs @@ -0,0 +1,21 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +const pkg = process.env.MUTATE_PACKAGE; +const base = pkg ? `packages/${pkg}` : "packages"; + +/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */ +export default { + packageManager: "npm", + testRunner: "command", + commandRunner: { + command: `node --test --conditions=node --test-force-exit ./${base}/**/*.test.js`, + }, + coverageAnalysis: "off", + mutate: [`${base}/**/*.node.mjs`, "!**/*.map", "!**/node_modules/**"], + plugins: ["@stryker-mutator/*"], + reporters: ["progress", "clear-text"], + thresholds: { high: 100, low: 100, break: 100 }, + tempDirName: pkg + ? `/tmp/stryker/@datastream/${pkg}` + : "/tmp/stryker/@datastream", +}; diff --git a/stryker.node-test.config.json b/stryker.node-test.config.json new file mode 100644 index 0000000..75a973d --- /dev/null +++ b/stryker.node-test.config.json @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "packageManager": "npm", + "testRunner": "node-test", + "nodeTest": { + "testFiles": ["packages/encrypt/**/*.test.js"], + "nodeArgs": ["--conditions=node"], + "concurrency": false + }, + "coverageAnalysis": "perTest", + "mutate": [ + "packages/encrypt/**/*.node.mjs", + "!**/*.map", + "!**/node_modules/**" + ], + "plugins": ["@stryker-mutator/node-test-runner"], + "reporters": ["progress", "clear-text"], + "thresholds": { "high": 100, "low": 100, "break": 0 }, + "tempDirName": "/tmp/stryker/@datastream/node-test" +} diff --git a/websites/datastream.js.org/.tardisec.json b/websites/datastream.js.org/.tardisec.json new file mode 100644 index 0000000..7ff8579 --- /dev/null +++ b/websites/datastream.js.org/.tardisec.json @@ -0,0 +1,35 @@ +{ + "dns": { + "txt": ["tardisec-verification=9a3890fa-a783-51c2-b20f-0dbe457d526f"] + }, + "http": { + "headers": { + "Connection-Allowlist": "(\"*\");report-to=default", + "Connection-Allowlist-Report-Only": "();report-to=default", + "Content-Security-Policy": "base-uri 'none';connect-src 'self';default-src 'report-sample' 'report-sha256';form-action 'self';frame-ancestors 'none';img-src 'self';manifest-src 'self';report-to default;report-uri https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org;script-src 'self' 'report-sample' 'report-sha256';script-src-attr 'report-sample' 'report-sha256';style-src 'self' 'report-sample' 'report-sha256';style-src-attr 'report-sample' 'report-sha256';upgrade-insecure-requests;worker-src 'self'", + "Content-Security-Policy-Report-Only": "base-uri 'none';default-src 'report-sample' 'report-sha256';form-action 'none';frame-ancestors 'none';report-to default;report-uri https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org;require-trusted-types-for 'script'", + "Cross-Origin-Embedder-Policy": "require-corp;report-to=default", + "Cross-Origin-Opener-Policy": "same-origin;report-to=default", + "Cross-Origin-Resource-Policy": "same-origin", + "Document-Isolation-Policy": "isolate-and-require-corp;report-to=\"default\"", + "Document-Policy-Report-Only": "*;report-to=default", + "Integrity-Policy": "blocked-destinations=(),endpoints=(default)", + "Integrity-Policy-Report-Only": "blocked-destinations=(script style),endpoints=(default)", + "NEL": "{\"failure_fraction\":1,\"include_subdomains\":true,\"max_age\":31536000,\"report_to\":\"default\",\"success_fraction\":0}", + "Origin-Agent-Cluster": "?1", + "Permissions-Policy-Report-Only": "accelerometer=(),all-screens-capture=(),ambient-light-sensor=(),aria-notify=(),attribution-reporting=(),autofill=(),autoplay=(),battery=(),bluetooth=(),browsing-topics=(),camera=(),captured-surface-control=(),ch-ua=(),ch-ua-arch=(),ch-ua-bitness=(),ch-ua-full-version=(),ch-ua-full-version-list=(),ch-ua-high-entropy-values=(),ch-ua-mobile=(),ch-ua-model=(),ch-ua-platform=(),ch-ua-platform-version=(),clipboard-read=(),clipboard-write=(),compute-pressure=(),cross-origin-isolated=(),deferred-fetch=(),digital-credentials-create=(),digital-credentials-get=(),direct-sockets=(),display-capture=(),encrypted-media=(),execution-while-not-rendered=(),execution-while-out-of-viewport=(),focus-without-user-activation=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),hid=(),identity-credentials-get=(),idle-detection=(),interest-cohort=(),join-ad-interest-group=(),keyboard-map=(),language-detector=(),language-model=(),local-fonts=(),local-network=(),local-network-access=(),loopback-network=(),magnetometer=(),manual-text=(),mediasession=(),microphone=(),midi=(),monetization=(),navigation-override=(),on-device-speech-recognition=(),otp-credentials=(),payment=(),picture-in-picture=(),private-state-token-issuance=(),private-state-token-redemption=(),publickey-credentials-create=(),publickey-credentials-get=(),rewriter=(),run-ad-auction=(),screen-wake-lock=(),serial=(),smart-card=(),speaker-selection=(),storage-access=(),summarizer=(),sync-script=(),sync-xhr=(),tools=(),translator=(),trust-token-redemption=(),unload=(),usb=(),vertical-scroll=(),web-share=(),window-management=(),writer=(),xr-spatial-tracking=()", + "Referrer-Policy": "no-referrer", + "Report-To": "{\"endpoints\":[{\"url\":\"https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org\"}],\"group\":\"default\",\"include_subdomains\":true,\"max_age\":31536000}", + "Reporting-Endpoints": "default=\"https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org\"", + "Require-Document-Policy-Report-Only": "*;report-to=default", + "Scripting-Policy": "report-to=default", + "Scripting-Policy-Report-Only": "eval=blocked, report-to=default", + "Strict-Transport-Security": "max-age=31536000;includeSubDomains;preload", + "X-Content-Type-Options": "nosniff", + "X-DNS-Prefetch-Control": "off", + "X-Download-Options": "noopen", + "X-Frame-Options": "DENY", + "X-Permitted-Cross-Domain-Policies": "none" + } + } +} diff --git a/websites/datastream.js.org/.tardisec.sveltekit.json b/websites/datastream.js.org/.tardisec.sveltekit.json new file mode 100644 index 0000000..7f3ec17 --- /dev/null +++ b/websites/datastream.js.org/.tardisec.sveltekit.json @@ -0,0 +1,37 @@ +{ + "kit": { + "csp": { + "mode": "hash", + "directives": { + "base-uri": ["none"], + "connect-src": ["self"], + "default-src": ["report-sample", "'report-sha256'"], + "form-action": ["self"], + "frame-ancestors": ["none"], + "img-src": ["self"], + "manifest-src": ["self"], + "report-to": ["default"], + "report-uri": [ + "https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org" + ], + "script-src": ["self", "report-sample", "'report-sha256'"], + "script-src-attr": ["report-sample", "'report-sha256'"], + "style-src": ["self", "report-sample", "'report-sha256'"], + "style-src-attr": ["report-sample", "'report-sha256'"], + "upgrade-insecure-requests": true, + "worker-src": ["self"] + }, + "reportOnly": { + "base-uri": ["none"], + "default-src": ["report-sample", "'report-sha256'"], + "form-action": ["none"], + "frame-ancestors": ["none"], + "report-to": ["default"], + "report-uri": [ + "https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org" + ], + "require-trusted-types-for": ["script"] + } + } + } +} diff --git a/websites/datastream.js.org/package.json b/websites/datastream.js.org/package.json index 906d45a..554c530 100644 --- a/websites/datastream.js.org/package.json +++ b/websites/datastream.js.org/package.json @@ -2,8 +2,12 @@ "name": "datastream.js.org", "description": "SvelteKit SSR", "private": true, - "version": "0.5.0", + "version": "0.6.1", "type": "module", + "engines": { + "node": ">=24" + }, + "engineStrict": true, "scripts": { "start": "vite dev", "preview": "vite preview", @@ -14,36 +18,31 @@ "build:styles:extract": "ds extract --input-dir ./.svelte-kit --theme ./src/styles/theme.css --output-dir src/styles/", "build:styles:optimize": "ds optimize-styles ./.svelte-kit", "release:login": "wrangler login --browser false", - "release:domain": "curl https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/pages/projects/datastream/domains -H 'Content-Type: application/json' -H 'Authorization: Bearer ${API_KEY_W_PAGES_EDIT}' -d '{\"name\": \"datastream.js.org\"}'", + "release:domain": "curl https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/pages/projects/datastreamjs/domains -H 'Content-Type: application/json' -H 'Authorization: Bearer ${API_KEY_W_PAGES_EDIT}' -d '{\"name\": \"datastream.js.org\"}'", "release:deploy": "wrangler pages deploy" }, "dependencies": { "@plausible-analytics/tracker": "0.4.5", - "@willfarrell-ds/svelte": "0.0.0-alpha.6", - "@willfarrell-ds/vanilla": "0.0.0-alpha.6", + "@willfarrell-ds/svelte": "0.0.0-alpha.8", + "@willfarrell-ds/vanilla": "0.0.0-alpha.8", "hast-util-to-string": "3.0.1", "mdast-util-to-string": "4.0.0", - "uuid": "14.0.0" + "uuid": "14.0.1" }, "devDependencies": { "@sveltejs/adapter-cloudflare": "^7.0.0", - "@sveltejs/kit": "^2.64.0", + "@sveltejs/kit": "^2.60.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@willfarrell-ds/cli": "0.0.0-alpha.6", - "esbuild": "^0.28.1", + "@willfarrell-ds/cli": "0.0.0-alpha.8", + "esbuild": "^0.28.0", "mdsvex": "^0.12.0", - "svelte": "^5.56.3", + "svelte": "^5.55.7", "svgo": "^4.0.0", - "vite": "^8.0.16", + "vite": "^8.0.12", "vite-plugin-llms": "^1.0.0", - "vite-plugin-mkcert": "^2.1.0", + "vite-plugin-mkcert": "^2.0.0", "vite-plugin-sitemap": "^0.8.0", "vite-plugin-sri": "^0.0.2", - "wrangler": "^4.99.0" - }, - "overrides": { - "@sveltejs/kit": { - "cookie": "0.7.2" - } + "wrangler": "^4.0.0" } } diff --git a/websites/datastream.js.org/src/components/BodyFooter.svelte b/websites/datastream.js.org/src/components/BodyFooter.svelte index b5b4cc8..40e9916 100644 --- a/websites/datastream.js.org/src/components/BodyFooter.svelte +++ b/websites/datastream.js.org/src/components/BodyFooter.svelte @@ -2,10 +2,6 @@ import BodyFooter from "@design-system/components/BodyFooter.svelte"; import Image from "@design-system/components/Image.svelte"; import A from "@design-system/elements/a.svelte"; -import { page } from "$app/state"; - -const { children } = $props(); -const { url, params, data, form } = page; const navLinks = { Docs: { diff --git a/websites/datastream.js.org/src/components/BodyHeader.svelte b/websites/datastream.js.org/src/components/BodyHeader.svelte index 93c02cf..3fae7c6 100644 --- a/websites/datastream.js.org/src/components/BodyHeader.svelte +++ b/websites/datastream.js.org/src/components/BodyHeader.svelte @@ -12,7 +12,7 @@ import Span from "@design-system/elements/span.svelte"; import webComponentUrl from "@willfarrell-ds/vanilla/components/ds-input-focus.js?worker&url"; import { page } from "$app/state"; -const { params, data, form } = page; +const { data, form } = page; const navTopLinks = { [`v${data.version}`]: true, diff --git a/websites/datastream.js.org/src/components/docs/AsideNav.svelte b/websites/datastream.js.org/src/components/docs/AsideNav.svelte index 3d46123..278d21f 100644 --- a/websites/datastream.js.org/src/components/docs/AsideNav.svelte +++ b/websites/datastream.js.org/src/components/docs/AsideNav.svelte @@ -1,8 +1,5 @@ diff --git a/websites/datastream.js.org/src/routes/+page.svelte b/websites/datastream.js.org/src/routes/+page.svelte index 51d4580..91872bf 100644 --- a/websites/datastream.js.org/src/routes/+page.svelte +++ b/websites/datastream.js.org/src/routes/+page.svelte @@ -190,6 +190,26 @@ await pipeline(streams)`;

string

String streams, splitting, replacing, and counting.

+ +

arrow

+

Apache Arrow record batch transform streams.

+
+ +

duckdb

+

DuckDB writable streams for rows and Arrow batches.

+
+ +

kafka

+

Kafka producer and consumer streams.

+
+ +

protobuf

+

Protobuf encode, decode, and length-prefix framing.

+
+ +

schema-registry

+

Confluent and Glue Schema Registry framing.

+
diff --git a/websites/datastream.js.org/src/routes/docs/+layout.svelte b/websites/datastream.js.org/src/routes/docs/+layout.svelte index 163c02b..895123d 100644 --- a/websites/datastream.js.org/src/routes/docs/+layout.svelte +++ b/websites/datastream.js.org/src/routes/docs/+layout.svelte @@ -1,5 +1,6 @@ diff --git a/websites/datastream.js.org/src/routes/docs/packages/arrow/+page.md b/websites/datastream.js.org/src/routes/docs/packages/arrow/+page.md new file mode 100644 index 0000000..a9dc332 --- /dev/null +++ b/websites/datastream.js.org/src/routes/docs/packages/arrow/+page.md @@ -0,0 +1,123 @@ +--- +title: arrow +description: Apache Arrow record batch transform streams. +--- + +Apache Arrow record batch transform streams. Convert rows to and from Arrow `RecordBatch` objects and detect a schema from sampled data. + +## Install + +```bash +npm install @datastream/arrow apache-arrow +``` + +`apache-arrow` is a peer dependency. + +## `arrowDetectSchemaStream` PassThrough + +Samples the first rows of the stream and infers an Arrow `Schema`. Rows pass through unchanged. Accepts either arrays (columns become `column0`, `column1`, …) or objects (columns become object keys). + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `sampleSize` | `number` | `100` | Number of rows to buffer before sealing the schema | +| `resultKey` | `string` | `"arrowDetectSchema"` | Key in pipeline result | + +### Result + +```javascript +{ schema: Schema | null, fields: string[] | null } +``` + +### Type inference + +| Value | Arrow type | +|-------|------------| +| `boolean` | `Bool` | +| Integer in signed 32-bit range | `Int32` | +| Integer outside 32-bit range / non-integer `number` | `Float64` | +| `Date` | `TimestampMillisecond` | +| Anything else | `Utf8` | + +Integers outside the signed 32-bit range widen to `Float64` (exact up to 2^53) instead of silently wrapping inside an `Int32` builder. + +### Example + +```javascript +import { pipeline, createReadableStream } from '@datastream/core' +import { arrowDetectSchemaStream } from '@datastream/arrow' + +const detect = arrowDetectSchemaStream() + +const result = await pipeline([ + createReadableStream([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]), + detect, +]) + +console.log(result.arrowDetectSchema.fields) // ['id', 'name'] +``` + +## `arrowBatchFromArrayStream` Transform + +Builds Arrow `RecordBatch` objects from incoming array rows. Each row is an array of column values in schema field order. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `schema` | `Schema \| () => Schema` | — | Arrow schema, or a lazy function returning one (required) | +| `batchSize` | `number` | `10000` | Rows per emitted `RecordBatch` | + +## `arrowBatchFromObjectStream` Transform + +Builds Arrow `RecordBatch` objects from incoming object rows, mapping each schema field name to the matching object key. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `schema` | `Schema \| () => Schema` | — | Arrow schema, or a lazy function returning one (required) | +| `batchSize` | `number` | `10000` | Rows per emitted `RecordBatch` | + +### Example + +```javascript +import { pipeline, createReadableStream } from '@datastream/core' +import { arrowDetectSchemaStream, arrowBatchFromObjectStream } from '@datastream/arrow' + +const detect = arrowDetectSchemaStream() + +await pipeline([ + createReadableStream(rows), + detect, + arrowBatchFromObjectStream({ + schema: () => detect.result().value.schema, + }), + // ... e.g. duckdbArrowInsertStream +]) +``` + +## `arrowToArrayStream` Transform + +Expands each incoming `RecordBatch` into array rows (one array of column values per row). + +## `arrowToObjectStream` Transform + +Expands each incoming `RecordBatch` into object rows, keyed by the batch schema's field names. + +### Example + +```javascript +import { pipeline } from '@datastream/core' +import { arrowToObjectStream } from '@datastream/arrow' + +await pipeline([ + recordBatchReadableStream, + arrowToObjectStream(), + // ... each chunk is { id, name, ... } +]) +``` diff --git a/websites/datastream.js.org/src/routes/docs/packages/duckdb/+page.md b/websites/datastream.js.org/src/routes/docs/packages/duckdb/+page.md new file mode 100644 index 0000000..938403e --- /dev/null +++ b/websites/datastream.js.org/src/routes/docs/packages/duckdb/+page.md @@ -0,0 +1,124 @@ +--- +title: duckdb +description: DuckDB writable streams for inserting rows and Arrow record batches. +--- + +DuckDB writable streams. Insert rows or Apache Arrow `RecordBatch` objects directly into a DuckDB table. + +## Install + +```bash +npm install @datastream/duckdb +``` + +DuckDB is provided through optional peer dependencies. Install the runtime for your platform: + +| Platform | Package | +|----------|---------| +| Node.js | `@duckdb/node-api` | +| Browser | `@duckdb/duckdb-wasm` | + +The Arrow insert stream also requires `apache-arrow`. + +## `duckdbConnect` + +Opens a DuckDB connection. Defaults to an in-memory database. + +| Argument | Type | Default | Description | +|----------|------|---------|-------------| +| `path` | `string` | `":memory:"` | Database file path, or `:memory:` | +| `options` | `object` | — | DuckDB instance options | + +```javascript +import { duckdbConnect } from '@datastream/duckdb' + +const db = await duckdbConnect() // in-memory +``` + +## `duckdbAppenderStream` Writable + +Appends array or object rows into an existing table using the DuckDB appender. Returns a Promise resolving to the writable stream. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `db` | `Connection` | — | DuckDB connection from `duckdbConnect` (required) | +| `table` | `string` | — | Target table name (required) | +| `schema` | `Schema \| () => Schema` | — | Arrow schema; when provided and the table does not exist, the table is created from it | + +When no `schema` is given, the target table must already exist; its column names are read from the table. Array rows are inserted in column order, object rows by column name. + +### Example + +```javascript +import { pipeline, createReadableStream } from '@datastream/core' +import { duckdbConnect, duckdbAppenderStream } from '@datastream/duckdb' + +const db = await duckdbConnect() +await db.run('CREATE TABLE people (id INTEGER, name VARCHAR)') + +await pipeline([ + createReadableStream([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]), + await duckdbAppenderStream({ db, table: 'people' }), +]) +``` + +## `duckdbArrowInsertStream` Writable + +Inserts Apache Arrow `RecordBatch` objects into a table. Returns a Promise resolving to the writable stream. Pairs with `arrowBatchFromObjectStream` / `arrowBatchFromArrayStream` from `@datastream/arrow`. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `db` | `Connection` | — | DuckDB connection from `duckdbConnect` (required) | +| `table` | `string` | — | Target table name (required) | +| `schema` | `Schema \| () => Schema` | — | Arrow schema; when provided and the table does not exist, the table is created from it | + +### Example + +```javascript +import { pipeline, createReadableStream } from '@datastream/core' +import { arrowDetectSchemaStream, arrowBatchFromObjectStream } from '@datastream/arrow' +import { duckdbConnect, duckdbArrowInsertStream } from '@datastream/duckdb' + +const db = await duckdbConnect() +const detect = arrowDetectSchemaStream() + +await pipeline([ + createReadableStream(rows), + detect, + arrowBatchFromObjectStream({ schema: () => detect.result().value.schema }), + await duckdbArrowInsertStream({ + db, + table: 'events', + schema: () => detect.result().value.schema, + }), +]) +``` + +## Arrow type mapping + +When creating a table from an Arrow schema, types map to DuckDB columns as follows: + +| Arrow type | DuckDB type | +|------------|-------------| +| `Bool` | `BOOLEAN` | +| `Int8` / `Int16` / `Int32` / `Int64` | `TINYINT` / `SMALLINT` / `INTEGER` / `BIGINT` | +| `Uint8` / `Uint16` / `Uint32` / `Uint64` | `UTINYINT` / `USMALLINT` / `UINTEGER` / `UBIGINT` | +| `Float32` / `Float64` | `REAL` / `DOUBLE` | +| `DateDay` / `DateMillisecond` | `DATE` | +| `TimestampSecond` / `TimestampMillisecond` / `TimestampMicrosecond` / `TimestampNanosecond` | `TIMESTAMP_S` / `TIMESTAMP_MS` / `TIMESTAMP` / `TIMESTAMP_NS` | +| anything else | `VARCHAR` | + +## Platform support + +| Feature | Node.js | Browser | +|---------|---------|---------| +| `duckdbConnect` | `@duckdb/node-api` | `@duckdb/duckdb-wasm` | +| `duckdbAppenderStream` | yes | yes | +| `duckdbArrowInsertStream` | yes | yes (requires `apache-arrow`) | diff --git a/websites/datastream.js.org/src/routes/docs/packages/kafka/+page.md b/websites/datastream.js.org/src/routes/docs/packages/kafka/+page.md new file mode 100644 index 0000000..5a34c38 --- /dev/null +++ b/websites/datastream.js.org/src/routes/docs/packages/kafka/+page.md @@ -0,0 +1,128 @@ +--- +title: kafka +description: Kafka producer and consumer streams built on kafkajs. +--- + +Kafka producer and consumer streams built on [kafkajs](https://kafka.js.org). Produce messages from a writable stream and consume them from a readable stream with end-to-end backpressure. + +## Install + +```bash +npm install @datastream/kafka kafkajs +``` + +`kafkajs` is an optional peer dependency. + +## `kafkaConnect` + +Creates a Kafka client and connects a producer and/or consumer. Returns a connection object. A consumer is created only when a `groupId` is provided; the producer is created unless `producer` is set to `false`. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `brokers` | `string[]` | — | Broker addresses | +| `clientId` | `string` | — | Client identifier | +| `ssl` | `object` | — | TLS configuration | +| `sasl` | `object` | — | SASL authentication (see `@datastream/aws` MSK IAM) | +| `groupId` | `string` | — | Consumer group id; required to create a consumer | +| `producer` | `object \| false` | `{}` | Producer options, or `false` to skip the producer | +| `consumer` | `object` | `{}` | Additional consumer options | + +### Result + +```javascript +{ kafka, producer, consumer, disconnect } +``` + +Call `await connection.disconnect()` to disconnect the producer and consumer. + +### Example + +```javascript +import { kafkaConnect } from '@datastream/kafka' + +const { producer, consumer, disconnect } = await kafkaConnect({ + brokers: ['localhost:9092'], + clientId: 'my-app', + groupId: 'my-group', +}) +``` + +## `kafkaProduceStream` Writable + +Batches messages and sends them to a topic. Returns a Promise resolving to the writable stream. Chunks may be a `Uint8Array`, a `string`, or a `{ value, key?, headers?, partition? }` message object. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `producer` | `Producer` | — | Producer from `kafkaConnect` (required) | +| `topic` | `string` | — | Destination topic (required) | +| `batchSize` | `number` | `100` | Messages buffered before a `send` | +| `acks` | `-1 \| 0 \| 1` | `-1` | Acknowledgements (`-1` = all in-sync replicas) | +| `compression` | `0 \| 1 \| 2 \| 3 \| 4` | — | None, GZIP, Snappy, LZ4, ZSTD | +| `timeout` | `number` | — | Per-request send timeout in ms | + +On a failed send the thrown error carries the un-sent batch on `err.failedMessages` so callers can re-queue instead of losing data. + +### Example + +```javascript +import { pipeline, createReadableStream } from '@datastream/core' +import { kafkaConnect, kafkaProduceStream } from '@datastream/kafka' + +const { producer } = await kafkaConnect({ brokers: ['localhost:9092'] }) + +await pipeline([ + createReadableStream([ + { value: 'event-1' }, + { value: 'event-2' }, + ]), + await kafkaProduceStream({ producer, topic: 'events' }), +]) +``` + +## `kafkaConsumeStream` Readable + +Subscribes to one or more topics and emits each message as a chunk. Returns a Promise resolving to a readable stream with a `stop()` method. Backpressure propagates from the downstream consumer through kafkajs to the broker. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `consumer` | `Consumer` | — | Consumer from `kafkaConnect` (required) | +| `topics` | `string \| string[]` | — | Topic(s) to subscribe to (required) | +| `fromBeginning` | `boolean` | `false` | Start from the earliest offset | +| `autoCommit` | `boolean` | `true` | Auto-commit offsets | +| `partitionsConsumedConcurrently` | `number` | `1` | Concurrent partition processing | +| `signal` | `AbortSignal` | — | Aborting stops the consumer | + +### Emitted chunk + +```javascript +{ topic, partition, offset, key, value, headers, timestamp } +``` + +### Example + +```javascript +import { kafkaConnect, kafkaConsumeStream } from '@datastream/kafka' + +const { consumer } = await kafkaConnect({ + brokers: ['localhost:9092'], + groupId: 'my-group', +}) + +const stream = await kafkaConsumeStream({ consumer, topics: 'events' }) + +for await (const message of stream) { + console.log(message.value) +} + +await stream.stop() +``` + +## Platform support + +Available on Node.js (via `kafkajs`). Not available in the browser. diff --git a/websites/datastream.js.org/src/routes/docs/packages/protobuf/+page.md b/websites/datastream.js.org/src/routes/docs/packages/protobuf/+page.md new file mode 100644 index 0000000..72a1a21 --- /dev/null +++ b/websites/datastream.js.org/src/routes/docs/packages/protobuf/+page.md @@ -0,0 +1,110 @@ +--- +title: protobuf +description: Protobuf encode/decode and length-prefix framing streams. +--- + +Protobuf encode/decode transform streams and length-prefix framing for delimited Protobuf records. Works with a [protobufjs](https://protobufjs.github.io/protobuf.js/)-style `Type` (anything exposing `encode`, `decode`, and `create`). + +## Install + +```bash +npm install @datastream/protobuf +``` + +Bring your own Protobuf runtime (for example `protobufjs`) to obtain message `Type` objects. + +## Type input + +Each encode/decode stream takes a `Type`. It may be: + +- a static `Type` value (cached on the hot path), or +- a function `(chunk) => Type` (sync or async), re-invoked per chunk so you can select a different message type per record (for example from a Schema Registry envelope). + +## `protobufEncodeStream` Transform + +Encodes each incoming message object into Protobuf wire bytes (`Uint8Array`). + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `Type` | `Type \| (chunk) => Type` | — | Protobuf message type, or a per-chunk resolver (required) | + +### Example + +```javascript +import { pipeline, createReadableStream } from '@datastream/core' +import { protobufEncodeStream } from '@datastream/protobuf' + +await pipeline([ + createReadableStream([{ id: 1, name: 'Alice' }]), + protobufEncodeStream({ Type: Person }), +]) +``` + +## `protobufDecodeStream` Transform + +Decodes Protobuf wire bytes into message objects. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `Type` | `Type \| (chunk) => Type` | — | Protobuf message type, or a per-chunk resolver (required) | +| `payload` | `(chunk) => Uint8Array` | identity | Extract the protobuf bytes from each chunk (e.g. from a registry envelope) | +| `maxOutputSize` | `number` | — | Maximum total input bytes; decoding aborts with an error when exceeded | + +#### Output size protection + +Decoding untrusted Protobuf can amplify a small input into large objects. Set `maxOutputSize` to bound total decoded volume by input bytes and abort before memory is exhausted. + +### Example + +```javascript +import { protobufDecodeStream } from '@datastream/protobuf' + +protobufDecodeStream({ Type: Person, maxOutputSize: 10 * 1024 * 1024 }) +``` + +## `protobufLengthPrefixFrameStream` Transform + +Prefixes each chunk with a base-128 varint length, producing a self-delimiting stream of framed records (the same framing Kafka and gRPC-style transports use). + +## `protobufLengthPrefixUnframeStream` Transform + +Reassembles a varint-length-prefixed byte stream back into individual record frames. Handles records split across chunk boundaries. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxMessageSize` | `number` | — | Reject any frame whose declared length exceeds this, aborting with an error | + +Throws on flush if trailing bytes do not form a complete frame, and on a varint that overflows. + +### Example + +```javascript +import { pipeline, createReadableStream } from '@datastream/core' +import { + protobufEncodeStream, + protobufLengthPrefixFrameStream, + protobufLengthPrefixUnframeStream, + protobufDecodeStream, +} from '@datastream/protobuf' + +// Encode + frame on the way out +await pipeline([ + createReadableStream(messages), + protobufEncodeStream({ Type: Person }), + protobufLengthPrefixFrameStream(), + // ... write framed bytes +]) + +// Unframe + decode on the way in +await pipeline([ + framedByteStream, + protobufLengthPrefixUnframeStream({ maxMessageSize: 1 * 1024 * 1024 }), + protobufDecodeStream({ Type: Person }), +]) +``` diff --git a/websites/datastream.js.org/src/routes/docs/packages/schema-registry/+page.md b/websites/datastream.js.org/src/routes/docs/packages/schema-registry/+page.md new file mode 100644 index 0000000..4afc074 --- /dev/null +++ b/websites/datastream.js.org/src/routes/docs/packages/schema-registry/+page.md @@ -0,0 +1,98 @@ +--- +title: schema-registry +description: Confluent and AWS Glue Schema Registry framing streams. +--- + +Framing and unframing transform streams for the Confluent and AWS Glue Schema Registry wire formats. Each input chunk is treated as one envelope (one Kafka message = one chunk = one framed record). + +## Install + +```bash +npm install @datastream/schema-registry +``` + +## Confluent format + +5-byte header: a `0x00` magic byte followed by a big-endian unsigned 32-bit schema id, then the payload bytes. + +### `confluentFrameStream` Transform + +Prepends the Confluent header to each payload chunk. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `schemaId` | `number` | — | Unsigned 32-bit schema id (required) | +| `resultKey` | `string` | `"confluentSchemaId"` | Key in pipeline result | + +### `confluentUnframeStream` Transform + +Validates the magic byte, reads the schema id, and emits a `{ schemaId, payload }` envelope. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `resultKey` | `string` | `"confluentSchemaId"` | Key in pipeline result | + +### Emitted envelope + +```javascript +{ schemaId: number, payload: Uint8Array } +``` + +### Example + +```javascript +import { pipeline, createReadableStream } from '@datastream/core' +import { confluentUnframeStream } from '@datastream/schema-registry' +import { protobufDecodeStream } from '@datastream/protobuf' + +await pipeline([ + framedByteStream, + confluentUnframeStream(), + protobufDecodeStream({ + // pick the Type per message from the envelope schemaId + Type: (envelope) => registry.get(envelope.schemaId), + payload: (envelope) => envelope.payload, + }), +]) +``` + +## Glue format + +18-byte header: a `0x03` magic byte, a 1-byte compression flag (`none` or `zlib`), and a 16-byte schema version UUID, then the payload bytes. + +### `glueFrameStream` Transform + +Prepends the Glue header to each payload chunk, optionally deflating the payload. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `schemaVersionId` | `string` | — | Schema version UUID (required) | +| `compression` | `"none" \| "zlib"` | `"none"` | Payload compression | +| `resultKey` | `string` | `"glueSchemaVersionId"` | Key in pipeline result | + +### `glueUnframeStream` Transform + +Validates the magic byte, reads the schema version UUID and compression flag, inflates `zlib` payloads, and emits a `{ schemaVersionId, compression, payload }` envelope. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxDecompressedBytes` | `number` | `10485760` (10MB) | Maximum decompressed payload size; aborts with an error when exceeded | +| `resultKey` | `string` | `"glueSchemaVersionId"` | Key in pipeline result | + +#### Decompression protection + +A malicious zlib payload can expand to far more than its framed size. `maxDecompressedBytes` caps the inflated output and aborts before memory is exhausted. Always keep this bounded for untrusted input. + +### Emitted envelope + +```javascript +{ schemaVersionId: string, compression: 'none' | 'zlib', payload: Uint8Array } +``` + +## Per-chunk envelopes vs `result()` + +Unframe streams emit `{ schemaId | schemaVersionId, payload }` envelopes downstream so a decoder can select the right schema per chunk. The `.result()` accessor is exposed for parity with the other detect streams, but it reflects only the most recently seen envelope and is racy under backpressure — prefer the per-chunk envelope when wiring a decoder. + +## Platform support + +Works on both Node.js and the browser; compression uses the platform `CompressionStream` / `DecompressionStream`. diff --git a/websites/datastream.js.org/src/routes/search/+page.svelte b/websites/datastream.js.org/src/routes/search/+page.svelte index bc7edb5..ecca25a 100644 --- a/websites/datastream.js.org/src/routes/search/+page.svelte +++ b/websites/datastream.js.org/src/routes/search/+page.svelte @@ -4,7 +4,6 @@ import H1 from "@design-system/components/Heading1.svelte"; import H2 from "@design-system/components/Heading2.svelte"; import LayoutCenter from "@design-system/components/LayoutCenter.svelte"; import A from "@design-system/elements/a.svelte"; -import Li from "@design-system/elements/li.svelte"; import P from "@design-system/elements/p.svelte"; import Section from "@design-system/elements/section.svelte"; import Span from "@design-system/elements/span.svelte"; diff --git a/websites/datastream.js.org/src/styles/below.css b/websites/datastream.js.org/src/styles/below.css index a45bf3b..d416549 100644 --- a/websites/datastream.js.org/src/styles/below.css +++ b/websites/datastream.js.org/src/styles/below.css @@ -1,3 +1,4 @@ +@import "@willfarrell-ds/vanilla/elements/blockquote.css"; @import "@willfarrell-ds/vanilla/elements/code.css"; @import "@willfarrell-ds/vanilla/elements/details.css"; @import "@willfarrell-ds/vanilla/elements/form.css"; @@ -11,7 +12,6 @@ @import "@willfarrell-ds/vanilla/elements/p.css"; @import "@willfarrell-ds/vanilla/elements/picture.css"; @import "@willfarrell-ds/vanilla/elements/pre.css"; -@import "@willfarrell-ds/vanilla/elements/s.css"; @import "@willfarrell-ds/vanilla/elements/select.css"; @import "@willfarrell-ds/vanilla/elements/table.css"; @import "@willfarrell-ds/vanilla/classes/span.badge.css"; diff --git a/websites/datastream.js.org/svelte.config.js b/websites/datastream.js.org/svelte.config.js index f26ddfe..82825f9 100644 --- a/websites/datastream.js.org/svelte.config.js +++ b/websites/datastream.js.org/svelte.config.js @@ -1,9 +1,9 @@ import { resolve } from "node:path"; import adapter from "@sveltejs/adapter-cloudflare"; import { mdsvex } from "mdsvex"; +import tardisec from "./.tardisec.sveltekit.json" with { type: "json" }; import { rehypeAddHeadingIds } from "./src/lib/rehype-add-heading-ids.js"; import { remarkExtractHeadings } from "./src/lib/remark-extract-headings.js"; -import tardisec from "./tardisec.json" with { type: "json" }; const domain = process.env.ORIGIN ?? "datastream.js.org"; const origin = domain; @@ -18,26 +18,7 @@ const config = { "@styles": resolve("./src/styles"), }, appDir: "_", - csp: { - ...tardisec["svelte.config.js"]["Content-Security-Policy"], - mode: "hash", - directives: { - "default-src": ["none"], - "base-uri": ["none"], - "connect-src": ["self"], - "form-action": ["self"], - "frame-ancestors": ["none"], - "img-src": ["self"], - "manifest-src": ["self"], - "script-src": ["self"], - "script-src-attr": ["report-sample"], - "style-src": ["self"], - "style-src-attr": ["report-sample"], - "upgrade-insecure-requests": true, - "worker-src": ["self"], - "report-to": ["default"], - }, - }, + csp: tardisec.kit.csp, csrf: { trustedOrigins: [origin], },