diff --git a/.env.example b/.env.example index 03b6ae69..2b1cf9f1 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,19 @@ STACKABLE_COCKPIT_OIDC_DISCOVERY_URL=https://your-idp.example.com/realms/your-re STACKABLE_COCKPIT_OIDC_CLIENT_ID=stackable-cockpit STACKABLE_COCKPIT_OIDC_CLIENT_SECRET=your-client-secret +# Storage encryption key (64 hex characters) used to encrypt sensitive data in storage. +STORAGE_ENCRYPTION_KEY=F000000000000000000000000000000000000000000000000000000000000000 +# STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT=/path/to/ca.pem # CA certificate for S3 connections with TLS verification; replaces the system trust store + +# PostgreSQL database (required) +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=cockpit +DATABASE_USER=cockpit +DATABASE_PASSWORD=change-me +# DATABASE_TLS_MODE=verify-full # verify-full, require or disable (default: verify-full in production, disable otherwise) +# DATABASE_TLS_CA_CERT=/path/to/ca.pem # CA certificate for verify-full + # OIDC claim used as the Trino user for impersonation (default: preferred_username) # STACKABLE_COCKPIT_TRINO_USER_CLAIM=preferred_username @@ -30,14 +43,39 @@ STACKABLE_COCKPIT_OIDC_CLIENT_SECRET=your-client-secret # the specified Trino endpoint is used automatically. By default the logged-in # user is forwarded to Trino as X-Trino-User (impersonation via the OIDC username). # STACKABLE_COCKPIT_TRINO_URL=https://trino.example.com:8443 +# STACKABLE_COCKPIT_TRINO_PUBLIC_URL=https://trino.example.com # Browser-facing Trino URL for "View in Trino" links (default: the Trino URL used for queries) # STACKABLE_COCKPIT_TRINO_AUTH_TYPE=basic # "none" (default) or "basic" # STACKABLE_COCKPIT_TRINO_AUTH_USERNAME=service-account # STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD=secret # STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION=false # Disable impersonation: run all queries as the auth user (requires basic auth). Default: true # STACKABLE_COCKPIT_TRINO_TLS_INSECURE=false # Set to "true" to skip TLS certificate verification # STACKABLE_COCKPIT_TRINO_TLS_CA_CERT=/path/to/ca.pem # Custom CA certificate file +# STACKABLE_COCKPIT_QUERY_TTL=1800 # Seconds before completed, unaccessed tab queries are evicted (default: 1800) # Feature flags # STACKABLE_COCKPIT_COMPLETION_ENABLED=false # Disable SQL editor code completion (default: true) # STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED=true # Enable S3/HDFS file browser (default: false) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_RESTORE_TABS=true # File browser: continue where you left off — restore open tabs from the last session (default: false) +# PUBLIC_STACKABLE_COCKPIT_INFINITE_SCROLL_ENABLED=true # Load CSV and Parquet preview rows lazily while scrolling; any other value than "true" disables it (default: true) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_CUT_COPY_ENABLED=true # Enables cut/copy and dragging in the storage browser; client-only, the server work is gated by paste/move (default: false) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_PASTE_ENABLED=true # Enables paste in the storage browser; the transfer API accepts copies only with this, and moves with this or MOVE_ENABLED (default: false) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_RENAME_ENABLED=true # Enables rename in the storage browser; also enforced by the transfer API (default: false) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_MOVE_ENABLED=true # Enables drag-and-drop move in the storage browser (dragging also needs CUT_COPY_ENABLED); the transfer API accepts moves with this or PASTE_ENABLED (default: false) + +# Storage browser limits and settings +# STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES=262144 # Max bytes fetched for text, TSV and JSON previews; editable files are fetched completely (default: 262144) +# STACKABLE_COCKPIT_IMAGE_PREVIEW_BYTES=5242880 # Max bytes fetched for image previews (default: 5242880) +# STACKABLE_COCKPIT_PDF_PREVIEW_BYTES=26214400 # Max bytes fetched for PDF previews (default: 26214400) +# STACKABLE_COCKPIT_FILE_PREVIEW_ROWS=250 # Max rows in tabular file previews (default: 250) +# STACKABLE_COCKPIT_FILE_PREVIEW_COLUMNS=50 # Max columns in tabular file previews (default: 50) +# STACKABLE_COCKPIT_ARCHIVE_PREVIEW_MAX_MB=100 # Max size in megabytes of an archive opened for preview, checked against its compressed size, the total size of the listed entries and the size of an extracted entry (default: 100) +# STACKABLE_COCKPIT_ARCHIVE_CACHE_MAX_MB=512 # Max total size of archives cached in /tmp for previews, in megabytes; raised to at least twice ARCHIVE_PREVIEW_MAX_MB (default: 512) +# STACKABLE_COCKPIT_PARQUET_PREVIEW_DISALLOWED_COMPRESSION_TYPES=gzip-no_offset # Comma-separated list of compression types to disallow from parquet data preview (default: "gzip-no_offset") +# BODY_SIZE_LIMIT=6M # adapter-node request body limit; must be at least MAX_EDITABLE_FILE_SIZE (default: 6M in the container image, 512K otherwise) +# PUBLIC_STACKABLE_COCKPIT_MAX_EDITABLE_FILE_SIZE=1048576 # Max file size (in bytes) of editable text files in the storage browser; BODY_SIZE_LIMIT must be at least this large (default: 5 MiB) +# PUBLIC_STACKABLE_COCKPIT_PAGE_SIZES=25,50,100 # Comma-separated page size options for paginated list views (default: 25,50,100) +# PUBLIC_STACKABLE_COCKPIT_DEFAULT_PAGE_SIZE=25 # Page size selected when no user preference is stored; must be one of PAGE_SIZES (default: first page size) +# PUBLIC_STACKABLE_COCKPIT_MAX_RECENT_FILES=15 # Number of recently visited storage locations kept in local storage (default: 15) # PUBLIC_STACKABLE_COCKPIT_UPLOAD_CONCURRENCY=3 # Maximum number of concurrent file uploads (default: 3) +# STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY=8 # Objects copied at once within one copy, move or rename request (default: 8) +# STACKABLE_COCKPIT_DOWNLOAD_HISTORY_RETENTION_DAYS=30 # Days a user's download history is kept; an integer from 1 to 3650, any other value stops startup (default: 30) diff --git a/.env.test b/.env.test index a9654788..24624272 100644 --- a/.env.test +++ b/.env.test @@ -11,10 +11,23 @@ STACKABLE_COCKPIT_IMAGE_PREVIEW_BYTES=5242880 STACKABLE_COCKPIT_PDF_PREVIEW_BYTES=26214400 STACKABLE_COCKPIT_FILE_PREVIEW_ROWS=250 STACKABLE_COCKPIT_FILE_PREVIEW_COLUMNS=50 -PUBLIC_STACKABLE_COCKPIT_STORAGE_AUTO_CONNECT=true +STACKABLE_COCKPIT_PARQUET_PREVIEW_DISALLOWED_COMPRESSION_TYPES=gzip-no_offset +PUBLIC_STACKABLE_COCKPIT_STORAGE_RESTORE_TABS=true PUBLIC_STACKABLE_COCKPIT_PAGE_SIZES=25,50,100 PUBLIC_STACKABLE_COCKPIT_DEFAULT_PAGE_SIZE=25 PUBLIC_STACKABLE_COCKPIT_MAX_RECENT_FILES=15 PUBLIC_STACKABLE_COCKPIT_UPLOAD_CONCURRENCY=3 +PUBLIC_STACKABLE_COCKPIT_INFINITE_SCROLL_ENABLED=true +PUBLIC_STACKABLE_COCKPIT_STORAGE_CUT_COPY_ENABLED=true +PUBLIC_STACKABLE_COCKPIT_STORAGE_PASTE_ENABLED=true +PUBLIC_STACKABLE_COCKPIT_STORAGE_RENAME_ENABLED=true +PUBLIC_STACKABLE_COCKPIT_STORAGE_MOVE_ENABLED=true +# One object at a time, so the cancel E2E test can stop a move mid-way. +STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY=1 GARAGE_ADMIN_URL=http://localhost:30902 GARAGE_ADMIN_TOKEN=stackable-cockpit-e2e-admin-token +BETTER_AUTH_URL=http://localhost:4173 +BETTER_AUTH_SECRET=stackable-cockpit-e2e-better-auth-secret +DATABASE_PASSWORD=e2e-build-placeholder-password +STORAGE_ENCRYPTION_KEY=a781775ee543107bfa97191691bb23c982dd9d9550542df27194ebc09d617dfd +PREVIEW_PORT=4173 diff --git a/.github/workflows/pr_checks.yaml b/.github/workflows/pr_checks.yaml index 94d0dedb..47d92429 100644 --- a/.github/workflows/pr_checks.yaml +++ b/.github/workflows/pr_checks.yaml @@ -59,7 +59,7 @@ jobs: run: python -m pip install pre-commit - name: Run pre-commit - run: pre-commit run --all-files --show-diff-on-failure --color=always + run: pre-commit run --all-files --hook-stage manual --show-diff-on-failure --color=always unit-tests: name: Unit Tests @@ -96,10 +96,9 @@ jobs: with: file-coverage-mode: changes - e2e-with-garage: - name: E2E Tests with Garage S3 + e2e-tests: + name: E2E Tests with Garage S3 and PostgreSQL runs-on: ubuntu-latest - steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -118,35 +117,13 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium firefox - - name: Start Garage S3 and initialize bucket - run: | - # GitHub Actions services now support entrypoint + command, but we still - # start Garage manually here because service containers are created before - # actions/checkout runs. The upstream Garage image contains only the - # /garage binary (no /bin/sh), so the service cannot wait for the checked- - # out e2e/garage.toml to appear before it starts. - docker run -d --name garage \ - -p 3900:3900 \ - -p 3902:3902 \ - -e GARAGE_CONFIG_FILE=/workspace/dev/garage/garage.toml \ - -v "$PWD:/workspace" \ - --entrypoint /garage \ - oci.stackable.tech/stackable/dxflrs/garage:v2.3.0 \ - server --single-node - - # Wait for the S3 API to become available - for _ in $(seq 1 30); do - if curl -s http://localhost:3900 >/dev/null 2>&1; then - break - fi - sleep 1 - done - - S3_SECRET_ACCESS_KEY="e2e-test-secret-key-for-ci" \ - GARAGE_ADMIN_TOKEN='stackable-cockpit-e2e-admin-token' \ - S3_ENDPOINT='http://localhost:3900' \ - S3_CONFIG_PATH="$PWD/s3-config.json" \ - ./e2e/init-garage-s3.sh - - - name: Run E2E tests with Garage - run: npm run test:e2e:garage + - name: Run E2E tests with Garage and PostgreSQL + run: npm run test:e2e + + - name: Upload Playwright traces + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-traces + path: e2e/test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 871677f2..d72b4d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ src/lib/editor/generated/ .wrangler .svelte-kit /build +/reports/ # OS .DS_Store @@ -32,6 +33,7 @@ dist/ # Claude Code - ignore local/personal files, commit shared config .claude/settings.local.json .claude/CLAUDE.local.md +.claude/worktrees/ # Vitest coverage reports coverage/ @@ -40,6 +42,7 @@ coverage/* # Playwright e2e/test-results e2e/.auth +.playwright/ # Paraglide src/lib/paraglide project.inlang/cache/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..df6ae337 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.21.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f7255df3..dff6787b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,27 +53,45 @@ repos: - repo: local hooks: + # ── Fix variant (runs locally on commit — auto-fixes where possible) ── + - id: npm-lint-fix + name: npm lint with --fix (prettier + eslint) + language: system + entry: npm run lint:fix + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.(svelte|ts|js|css|html|json|md)$ + + # ── Check-only variant (runs in CI — no auto-fix) ── - id: npm-lint - name: npm lint (prettier + eslint) + name: npm lint check only (prettier + eslint) language: system entry: npm run lint - stages: [pre-commit, pre-merge-commit] + stages: [manual] pass_filenames: false files: \.(svelte|ts|js|css|html|json|md)$ + # ── Shared hooks (same behaviour locally and in CI) ── - id: npm-check name: npm check (svelte-check) language: system entry: npm run check - stages: [pre-commit, pre-merge-commit] + stages: [pre-commit, pre-merge-commit, manual] pass_filenames: false files: \.(svelte|ts|js)$ + - id: npm-arch-test + name: npm arch test (fitness functions) + language: system + entry: npm run test:arch + stages: [pre-commit, pre-merge-commit, manual] + pass_filenames: false + - id: helm-lint name: helm lint language: system entry: helm lint deploy/helm/cockpit - stages: [pre-commit, pre-merge-commit] + stages: [pre-commit, pre-merge-commit, manual] pass_filenames: false files: ^deploy/helm/ @@ -85,7 +103,9 @@ repos: helm template test deploy/helm/cockpit --set auth.oidc.discoveryUrl=https://stub --set auth.oidc.clientId=stub - --set auth.oidc.clientSecret.secretKeyRef.name=stub > /dev/null' - stages: [pre-commit, pre-merge-commit] + --set auth.oidc.clientSecret.secretKeyRef.name=stub + --set database.host=stub + --set database.password.secretKeyRef.name=stub > /dev/null' + stages: [pre-commit, pre-merge-commit, manual] pass_filenames: false files: ^deploy/helm/ diff --git a/.prettierignore b/.prettierignore index 6dbc7f7c..cf40102e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,6 +13,8 @@ build/ .output/ e2e/test-results/ e2e/.auth/ +.playwright/ +reports/ src/lib/paraglide/ project.inlang/ @@ -33,3 +35,7 @@ coverage/ # Helm templates deploy/helm/ dev/garage/ +dev/postgresql/ + +# Claude Code worktrees (full repo copies) +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 68489856..f72be59c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,10 +188,10 @@ The application uses **Paraglide-JS v2** for type-safe, compiler-based internati ### Node.js Version -The required Node.js version is pinned in `.node-version`. Use `nvm` to install and activate it before running any `npm` commands — `npm` will hard-fail with engine errors otherwise (`.npmrc` sets `engine-strict=true`). +The required Node.js version is pinned in `.node-version` for CI and `.nvmrc` for NVM. Keep both files synchronised. Use `nvm` to install and activate it before running any `npm` commands — `npm` will hard-fail with engine errors otherwise (`.npmrc` sets `engine-strict=true`). ```bash -nvm install # installs the version from .node-version +nvm install # installs the version from .nvmrc nvm use # activates it in the current shell ``` @@ -224,6 +224,36 @@ pre-commit run --all-files # Run all checks: lint (prettier + eslint), type ch # markdownlint, yamllint, shellcheck, actionlint, hadolint, helm lint ``` +### Architecture Fitness Functions + +```bash +npm run test:arch # Run all architecture fitness function tests (~5 s) +npm run test:arch:report # Same, plus generate HTML dependency/metrics reports in /reports/ +``` + +Architecture tests live in `src/architecture-tests/*.spec.ts` and use [ArchUnitTS](https://github.com/LukasNiessen/ArchUnitTS) together with plain Node.js `fs` checks. They run in CI and must remain **green at all times**. + +**Run `npm run test:arch` whenever you:** + +- Add a new file to `src/lib/server/` (verify it doesn't break client-boundary rules) +- Add a new Svelte component (PascalCase naming and UI-pattern checks) +- Add or remove message keys in `messages/en.json` (both locale files must stay in sync) +- Refactor the Trino sub-layer (circular-dependency rules) + +**What the fitness functions enforce:** + +| Category | What is checked | +| ------------------------ | ------------------------------------------------------------------------------------------------------------- | +| Server / Client Boundary | `src/lib/client`, `stores`, `storage`, `editor`, `types` must not import `src/lib/server/**` | +| No Circular Dependencies | `src/lib/**` (excluding Trino) and `src/routes/**` must be cycle-free | +| Naming Conventions | Stores → `*.svelte.ts`; lib components → PascalCase `.svelte`; server files → no `.svelte.ts` extension | +| Code Size Limits | `.ts` files < 2 400 LOC; `.svelte` files < 1 100 LOC; test files < 1 000 LOC | +| UI Pattern Enforcement | No native date inputs; `` must have `alt`; no clickable `
` or `` (with documented exceptions) | +| i18n Compliance | `messages/en.json` and `messages/de.json` must have the same keys; no static `aria-label="English text"` | + +**Extending the fitness functions:** +When you add a new architectural rule (e.g., a new layer, a new naming convention), add a new `.spec.ts` file in `src/architecture-tests/` following the existing patterns. Use archunit for TypeScript dependency/cycle rules and plain Node.js `fs` for content checks on Svelte files. + ### E2E Testing ```bash @@ -261,7 +291,7 @@ test('example test', async ({ page }) => { ### Authentication -Tests use a saved session from `e2e/.auth/user.json`. If tests fail with auth errors, run the auth setup to re-authenticate via OIDC. +Import `test` and `expect` from `e2e/support/fixtures.ts`, not `@playwright/test`. Its worker-scoped fixture logs each worker in via the mock OIDC server as its own user, so parallel workers never share server-side state. Opt out with `test.use({ storageState: { cookies: [], origins: [] } })`. ## Important Notes diff --git a/README.md b/README.md index 1cdb4827..a23f8a28 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ A Helm chart for Kubernetes is included under [`deploy/helm/cockpit`](./deploy/h helm install cockpit ./deploy/helm/cockpit -f your-values.yaml ``` -See the [chart README](./deploy/helm/cockpit/README.md) for the full list of values. +The chart needs an existing PostgreSQL database (`database.*` values). See the [chart README](./deploy/helm/cockpit/README.md) for the full list of values, and [docs/DATABASE.md](./docs/DATABASE.md) for the database settings, including TLS. ### Docker @@ -94,19 +94,35 @@ Create a `.env` file at the project root, or set them in your deployment environ ### Feature Flags -| Variable | Type | Default | Description | Example | -| ------------------------------------------- | -------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -| `STACKABLE_COCKPIT_COMPLETION_ENABLED` | `boolean` (`"false"` to disable) | `true` | Enables the SQL editor code-completion provider and the metadata endpoint. Set to `"false"` to fall back to plain syntax highlighting. | `STACKABLE_COCKPIT_COMPLETION_ENABLED=false` | -| `STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED` | `boolean` (`"true"` to enable) | `false` | Shows the S3/HDFS file browser in the sidebar and activates routes under `/storage`. Must be explicitly opted in to expose storage credentials and the file-browser UI. | `STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED=true` | +| Variable | Type | Default | Description | Example | +| --------------------------------------------------- | -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `STACKABLE_COCKPIT_COMPLETION_ENABLED` | `boolean` (`"false"` to disable) | `true` | Enables the SQL editor code-completion provider and the metadata endpoint. Set to `"false"` to fall back to plain syntax highlighting. | `STACKABLE_COCKPIT_COMPLETION_ENABLED=false` | +| `STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED` | `boolean` (`"true"` to enable) | `false` | Shows the S3/HDFS file browser in the sidebar and activates routes under `/storage`. Must be explicitly opted in to expose storage credentials and the file-browser UI. | `STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED=true` | +| `PUBLIC_STACKABLE_COCKPIT_STORAGE_CUT_COPY_ENABLED` | `boolean` (`"true"` to enable) | `false` | Shows Cut/Copy in the file browser and lets items be dragged. Client-only: it fills the clipboard, the S3 work happens on paste or move. | `PUBLIC_STACKABLE_COCKPIT_STORAGE_CUT_COPY_ENABLED=true` | +| `PUBLIC_STACKABLE_COCKPIT_STORAGE_PASTE_ENABLED` | `boolean` (`"true"` to enable) | `false` | Shows Paste in the file browser. When disabled, the server rejects copies via `/api/storage/transfer` with 404, and moves too unless the Move flag is enabled. | `PUBLIC_STACKABLE_COCKPIT_STORAGE_PASTE_ENABLED=true` | +| `PUBLIC_STACKABLE_COCKPIT_STORAGE_RENAME_ENABLED` | `boolean` (`"true"` to enable) | `false` | Shows Rename in the file browser. The server accepts renames via `/api/storage/transfer` only when this is enabled. | `PUBLIC_STACKABLE_COCKPIT_STORAGE_RENAME_ENABLED=true` | +| `PUBLIC_STACKABLE_COCKPIT_STORAGE_MOVE_ENABLED` | `boolean` (`"true"` to enable) | `false` | Enables drag-and-drop move in the file browser (dragging also needs cut/copy). The server accepts moves via `/api/storage/transfer` only when this or paste is enabled, since cut-and-paste also moves. | `PUBLIC_STACKABLE_COCKPIT_STORAGE_MOVE_ENABLED=true` | ### Storage Preview Limits -| Variable | Type | Default | Description | Example | -| --------------------------------------- | ----------------- | ------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------ | -| `STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES` | `integer` (bytes) | `262144` (256 KiB) | Maximum bytes fetched when previewing text, CSV, or JSON files. | `STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES=524288` | -| `STACKABLE_COCKPIT_IMAGE_PREVIEW_BYTES` | `integer` (bytes) | `5242880` (5 MiB) | Maximum bytes fetched when previewing image files. | `STACKABLE_COCKPIT_IMAGE_PREVIEW_BYTES=10485760` | -| `STACKABLE_COCKPIT_PDF_PREVIEW_BYTES` | `integer` (bytes) | `26214400` (25 MiB) | Maximum bytes fetched when previewing PDF files. | `STACKABLE_COCKPIT_PDF_PREVIEW_BYTES=52428800` | -| `STACKABLE_COCKPIT_FILE_PREVIEW_ROWS` | `integer` (rows) | `250` | Maximum number of rows included in a tabular file preview (e.g. Parquet converted to CSV). | `STACKABLE_COCKPIT_FILE_PREVIEW_ROWS=500` | +| Variable | Type | Default | Description | Example | +| --------------------------------------- | ----------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES` | `integer` (bytes) | `262144` (256 KiB) | Maximum bytes fetched when previewing text, TSV, or JSON files. Editable text files (text types except CSV/TSV, up to `PUBLIC_STACKABLE_COCKPIT_MAX_EDITABLE_FILE_SIZE`) are always fetched completely. | `STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES=524288` | +| `STACKABLE_COCKPIT_IMAGE_PREVIEW_BYTES` | `integer` (bytes) | `5242880` (5 MiB) | Maximum bytes fetched when previewing image files. | `STACKABLE_COCKPIT_IMAGE_PREVIEW_BYTES=10485760` | +| `STACKABLE_COCKPIT_PDF_PREVIEW_BYTES` | `integer` (bytes) | `26214400` (25 MiB) | Maximum bytes fetched when previewing PDF files. | `STACKABLE_COCKPIT_PDF_PREVIEW_BYTES=52428800` | +| `STACKABLE_COCKPIT_FILE_PREVIEW_ROWS` | `integer` (rows) | `250` | Maximum number of rows included in a tabular file preview (e.g. Parquet converted to CSV). | `STACKABLE_COCKPIT_FILE_PREVIEW_ROWS=500` | + +### Storage Transfers + +| Variable | Type | Default | Description | Example | +| ------------------------------------------------ | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY` | `integer` | `8` | Objects copied at once within one copy, move or rename request. Copies run server-side in the storage backend (S3 CopyObject); no data streams through the app. A missing or invalid value falls back to the default. | `STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY=4` | + +### Request Body Size + +| Variable | Type | Default | Description | Example | +| ----------------- | ---------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `BODY_SIZE_LIMIT` | `string` (bytes) | `6M` in the container image (adapter: `512K`) | Maximum request body size accepted by the Node.js server ([adapter-node](https://svelte.dev/docs/kit/adapter-node#Environment-variables-BODY_SIZE_LIMIT)). Must be at least `PUBLIC_STACKABLE_COCKPIT_MAX_EDITABLE_FILE_SIZE`, otherwise saving larger files in the text editor fails. | `BODY_SIZE_LIMIT=12M` | ## Documentation diff --git a/deploy/helm/cockpit/README.md b/deploy/helm/cockpit/README.md index f8410f7c..c70aba91 100644 --- a/deploy/helm/cockpit/README.md +++ b/deploy/helm/cockpit/README.md @@ -6,15 +6,22 @@ This Helm chart deploys the Stackable Data Platform (SDP) Cockpit UI on Kubernet - A Kubernetes cluster - [Helm](https://helm.sh/docs/intro/install/) installed +- A PostgreSQL database. The chart does not deploy one. See [Database Parameters](#database-parameters). ## Installing the Chart ### Basic Installation ```bash -helm install cockpit ./deploy/helm/cockpit +kubectl create secret generic cockpit-database --from-literal=database-password='' + +helm install cockpit ./deploy/helm/cockpit \ + --set database.host=postgresql.example.svc \ + --set database.password.secretKeyRef.name=cockpit-database ``` +OIDC is enabled by default, so also set the `auth.oidc.*` values or disable it (see [Authentication Parameters](#authentication-parameters)). + ### Installation with Custom Values ```bash @@ -29,6 +36,10 @@ helm install cockpit ./deploy/helm/cockpit \ -f my-values.yaml ``` +### Upgrading + +New chart versions can add values. `helm upgrade --reuse-values` doesn't merge their defaults, so templates that read them fail to render. Use `--reset-then-reuse-values` instead. + ## Uninstalling the Chart ```bash @@ -83,6 +94,25 @@ The following table lists the configurable parameters of the Cockpit chart and t | Parameter | Description | Default | | --- | --- | --- | | `config.nodeEnv` | Node.js environment | `production` | +| `config.bodySizeLimit` | Sets `BODY_SIZE_LIMIT`, the maximum request body size (e.g. `12M`). Must be at least the editable file size limit (`PUBLIC_STACKABLE_COCKPIT_MAX_EDITABLE_FILE_SIZE`, 5 MiB by default), otherwise saving larger files in the text editor fails. | `""` (image default `6M`) | +| `config.archiveCacheMaxMb` | Sets `STACKABLE_COCKPIT_ARCHIVE_CACHE_MAX_MB`, the maximum total size in MB of the archive preview cache in `/tmp`. Keep it below `tmp.sizeLimit`. | `""` (app default `512`) | +| `tmp.sizeLimit` | Size limit of the `/tmp` emptyDir. The kubelet evicts the pod when it is exceeded. Keep it above `config.archiveCacheMaxMb`. Empty means no limit. | `1Gi` | + +### Database Parameters + +Cockpit stores sessions, users and storage connections in PostgreSQL. Migrations run automatically at startup. + +| Parameter | Description | Default | +| --- | --- | --- | +| `database.host` | PostgreSQL host. Required. | `""` | +| `database.port` | PostgreSQL port. | `5432` | +| `database.name` | Database name. | `cockpit` | +| `database.user` | Database user. | `cockpit` | +| `database.password.secretKeyRef.name` | Name of the Secret holding the database password. Required. | `""` | +| `database.password.secretKeyRef.key` | Key inside the Secret. | `database-password` | +| `database.tls.mode` | `verify-full` (verify certificate and host name), `require` (encrypt without verification) or `disable`. | `""` (application default: `verify-full` when `config.nodeEnv` is `production`, otherwise `disable`) | +| `database.tls.caCert` | Path to a CA certificate file inside the container, e.g. mounted via `extraVolumes`. Requires mode `verify-full`. Mutually exclusive with `secretClass`. | `""` | +| `database.tls.secretClass` | Stackable SecretClass that provides the database CA certificate. Requires mode `verify-full`. Mutually exclusive with `caCert`. | `""` | ### Feature Parameters @@ -92,16 +122,26 @@ All feature parameters are optional. Omit them to use the application defaults. | --- | --- | --- | | `features.trino.completion.enabled` | Enable SQL code completion. | `true` (application default) | | `features.storage.enabled` | Show the S3/HDFS file browser and enable routes under `/storage`. | `false` (application default) | -| `features.storage.autoConnect` | Reconnect to the most recently used storage connection when visiting the storage page. | `false` (application default) | | `features.storage.pageSizes` | Comma-separated page size options. | `25,50,100` (application default) | | `features.storage.defaultPageSize` | Default page size; must be included in `pageSizes`. | First configured page size (application default) | | `features.storage.maxRecentFiles` | Number of recently visited storage locations retained locally. | `15` (application default) | | `features.storage.uploadConcurrency` | Maximum parallel requests for file uploads. | `3` (application default) | -| `features.storage.textPreviewBytes` | Maximum bytes fetched for text, CSV, and JSON previews. | `262144` (application default) | +| `features.storage.textPreviewBytes` | Maximum bytes fetched for text, TSV, and JSON previews. Editable text files (text types except CSV/TSV, up to the editable file size limit) are always fetched completely. | `262144` (application default) | | `features.storage.imagePreviewBytes` | Maximum bytes fetched for image previews. | `5242880` (application default) | | `features.storage.pdfPreviewBytes` | Maximum bytes fetched for PDF previews. | `26214400` (application default) | | `features.storage.filePreviewRows` | Maximum rows in tabular file previews. | `250` (application default) | | `features.storage.filePreviewColumns` | Maximum columns in tabular file previews. | `50` (application default) | +| `features.storage.transferConcurrency` | Objects copied at once within one copy, move or rename request. | `8` (application default) | +| `features.storage.restoreTabs` | Restore open file browser tabs from the last session. | `false` (application default) | +| `features.storage.infiniteScroll` | Load CSV and Parquet preview rows lazily while scrolling. | `true` (application default) | +| `features.storage.cutCopy` | Show Cut/Copy and allow dragging items. Client-only: the server work happens on paste or move. | `false` (application default) | +| `features.storage.paste` | Show Paste. The transfer API accepts copies only when this is enabled, and moves when this or `move` is enabled. | `false` (application default) | +| `features.storage.rename` | Show Rename. The transfer API only accepts renames when this is enabled. | `false` (application default) | +| `features.storage.move` | Allow drag-and-drop move. Dragging also needs `cutCopy`. The transfer API accepts moves when this or `paste` is enabled. | `false` (application default) | +| `features.storage.maxEditableFileSize` | Maximum size in bytes of files that can be edited and saved in the text editor. `config.bodySizeLimit` must be at least this large. | `5242880` (application default) | +| `features.storage.archivePreviewMaxMb` | Maximum size in MB of an archive opened for preview, checked against its compressed size, the total size of the listed entries and the size of an extracted entry. The archive cache is raised to at least twice this value, so keep `tmp.sizeLimit` above that. | `100` (application default) | +| `features.storage.parquetPreviewDisallowedCompressionTypes` | Comma-separated Parquet compression codecs that are not previewed. A `-no_offset` suffix (e.g. `gzip-no_offset`) only blocks files without an offset index. | `gzip-no_offset` (application default) | +| `features.storage.downloadHistoryRetentionDays` | Days a user's download history is kept. Integer from 1 to 3650. | `30` (application default) | ### Authentication Parameters @@ -131,9 +171,9 @@ Optional pre-configured Trino endpoint. When `trino.url` is set, the in-app conn | `trino.auth.username` | Username for basic auth. | `""` | | `trino.auth.password.secretKeyRef.name` | Name of the Secret holding the Trino password. Required when `type=basic`. | `""` | | `trino.auth.password.secretKeyRef.key` | Key inside the Secret. | `trino-auth-password` | -| `trino.tls.insecure` | Skip TLS certificate verification. | `false` | -| `trino.tls.caCert` | Path to a custom CA certificate file. | `""` | -| `trino.tls.secretClass` | Stackable SecretClass that provides the Trino CA certificate. | `""` | +| `trino.tls.insecure` | Skip TLS certificate verification. Cannot be combined with `caCert` or `secretClass`. | `false` | +| `trino.tls.caCert` | Path to a CA certificate file inside the container, e.g. mounted via `extraVolumes`. Mutually exclusive with `secretClass`. | `""` | +| `trino.tls.secretClass` | Stackable SecretClass that provides the Trino CA certificate. Mutually exclusive with `caCert`. | `""` | | `trino.queryTtl` | Query result TTL in seconds. | `1800` (application default) | ### Security Parameters @@ -142,9 +182,22 @@ Optional pre-configured Trino endpoint. When `trino.url` is set, the in-app conn | --- | --- | --- | | `sessionSecret.secretKeyRef.name` | Name of a Secret holding a stable session secret. Auto-generated if empty. | `""` | | `sessionSecret.secretKeyRef.key` | Key inside the Secret. | `session-secret` | +| `storage.encryptionKey.secretKeyRef.name` | Name of the Secret holding the key that encrypts stored storage connection credentials: 64 hex characters, e.g. from `openssl rand -hex 32`. Required when `features.storage.enabled` is `true`. Keep it stable: changing or losing it makes all stored credentials unreadable. | `""` | +| `storage.encryptionKey.secretKeyRef.key` | Key inside the Secret. | `storage-encryption-key` | +| `storage.tls.caCert` | Path to a CA certificate file inside the container for S3 connections with TLS verification, e.g. mounted via `extraVolumes`. Mutually exclusive with `secretClass`. | `""` | +| `storage.tls.secretClass` | Stackable SecretClass that provides the CA certificate for S3 connections with TLS verification. Mutually exclusive with `caCert`. | `""` | | `podSecurityContext` | Pod security context | See values.yaml | | `securityContext` | Container security context | See values.yaml | +A configured CA certificate (`caCert` or `secretClass`) replaces the system trust store for that connection. For `storage.tls`, this applies to every S3 connection that verifies certificates, so connections to S3 endpoints with publicly trusted certificates (for example AWS) fail while it is set. The chart refuses to render contradictory TLS settings, for example a CA together with `trino.tls.insecure` or a database mode that doesn't verify certificates. + +### Volume Parameters + +| Parameter | Description | Default | +| --- | --- | --- | +| `extraVolumes` | Additional pod volumes, e.g. a Secret holding a CA certificate for `trino.tls.caCert`, `database.tls.caCert` or `storage.tls.caCert`. | `[]` | +| `extraVolumeMounts` | Additional volume mounts for the Cockpit container. | `[]` | + ### Resource Parameters | Parameter | Description | Default | diff --git a/deploy/helm/cockpit/templates/_helpers.tpl b/deploy/helm/cockpit/templates/_helpers.tpl index ea08d32d..9cca35d9 100644 --- a/deploy/helm/cockpit/templates/_helpers.tpl +++ b/deploy/helm/cockpit/templates/_helpers.tpl @@ -82,3 +82,43 @@ Image name {{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) }} {{- end }} {{- end }} + +{{/* +Fail on TLS settings that contradict each other, so that no setting is silently ignored. +*/}} +{{- define "cockpit.validateTls" -}} +{{- $trinoTls := .Values.trino.tls }} +{{- if and $trinoTls.caCert $trinoTls.secretClass }} +{{- fail "trino.tls.caCert and trino.tls.secretClass are mutually exclusive" }} +{{- end }} +{{- if and $trinoTls.insecure (or $trinoTls.caCert $trinoTls.secretClass) }} +{{- fail "trino.tls.insecure disables certificate verification, so trino.tls.caCert and trino.tls.secretClass would be ignored" }} +{{- end }} +{{- $dbTls := .Values.database.tls }} +{{- $validModes := list "verify-full" "require" "disable" }} +{{- if and $dbTls.mode (not (has $dbTls.mode $validModes)) }} +{{- fail (printf "database.tls.mode must be one of %s (got %q)" (join ", " $validModes) $dbTls.mode) }} +{{- end }} +{{- if and $dbTls.caCert $dbTls.secretClass }} +{{- fail "database.tls.caCert and database.tls.secretClass are mutually exclusive" }} +{{- end }} +{{- $dbMode := $dbTls.mode | default (ternary "verify-full" "disable" (eq .Values.config.nodeEnv "production")) }} +{{- if and (ne $dbMode "verify-full") (or $dbTls.caCert $dbTls.secretClass) }} +{{- fail (printf "database.tls.mode %q does not verify certificates, so database.tls.caCert and database.tls.secretClass would be ignored" $dbMode) }} +{{- end }} +{{- $storageTls := .Values.storage.tls }} +{{- if and $storageTls.caCert $storageTls.secretClass }} +{{- fail "storage.tls.caCert and storage.tls.secretClass are mutually exclusive" }} +{{- end }} +{{- end }} + +{{/* +Database port, validated: int64 turns "" or "abc" into 0, which would fail only at connect time. +*/}} +{{- define "cockpit.databasePort" -}} +{{- $port := .Values.database.port | int64 }} +{{- if or (lt $port 1) (gt $port 65535) }} +{{- fail (printf "database.port must be a port number between 1 and 65535 (got %v)" .Values.database.port) }} +{{- end }} +{{- $port }} +{{- end }} diff --git a/deploy/helm/cockpit/templates/deployment.yaml b/deploy/helm/cockpit/templates/deployment.yaml index a0f02654..420a5167 100644 --- a/deploy/helm/cockpit/templates/deployment.yaml +++ b/deploy/helm/cockpit/templates/deployment.yaml @@ -1,3 +1,4 @@ +{{- include "cockpit.validateTls" . }} apiVersion: apps/v1 kind: Deployment metadata: @@ -61,6 +62,14 @@ spec: value: {{ .Values.config.nodeEnv | quote }} - name: PORT value: {{ .Values.service.targetPort | quote }} + {{- with .Values.config.bodySizeLimit }} + - name: BODY_SIZE_LIMIT + value: {{ . | quote }} + {{- end }} + {{- with .Values.config.archiveCacheMaxMb }} + - name: STACKABLE_COCKPIT_ARCHIVE_CACHE_MAX_MB + value: {{ . | quote }} + {{- end }} {{- if hasKey $completion "enabled" }} - name: STACKABLE_COCKPIT_COMPLETION_ENABLED value: {{ index $completion "enabled" | quote }} @@ -70,10 +79,6 @@ spec: - name: STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED value: {{ .enabled | quote }} {{- end }} - {{- if hasKey . "autoConnect" }} - - name: PUBLIC_STACKABLE_COCKPIT_STORAGE_AUTO_CONNECT - value: {{ .autoConnect | quote }} - {{- end }} {{- with .pageSizes }} - name: PUBLIC_STACKABLE_COCKPIT_PAGE_SIZES value: {{ . | quote }} @@ -110,6 +115,50 @@ spec: - name: STACKABLE_COCKPIT_FILE_PREVIEW_COLUMNS value: {{ . | int64 | quote }} {{- end }} + {{- with .transferConcurrency }} + - name: STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY + value: {{ . | int64 | quote }} + {{- end }} + {{- if hasKey . "restoreTabs" }} + - name: PUBLIC_STACKABLE_COCKPIT_STORAGE_RESTORE_TABS + value: {{ .restoreTabs | quote }} + {{- end }} + {{- if hasKey . "infiniteScroll" }} + - name: PUBLIC_STACKABLE_COCKPIT_INFINITE_SCROLL_ENABLED + value: {{ .infiniteScroll | quote }} + {{- end }} + {{- if hasKey . "cutCopy" }} + - name: PUBLIC_STACKABLE_COCKPIT_STORAGE_CUT_COPY_ENABLED + value: {{ .cutCopy | quote }} + {{- end }} + {{- if hasKey . "paste" }} + - name: PUBLIC_STACKABLE_COCKPIT_STORAGE_PASTE_ENABLED + value: {{ .paste | quote }} + {{- end }} + {{- if hasKey . "rename" }} + - name: PUBLIC_STACKABLE_COCKPIT_STORAGE_RENAME_ENABLED + value: {{ .rename | quote }} + {{- end }} + {{- if hasKey . "move" }} + - name: PUBLIC_STACKABLE_COCKPIT_STORAGE_MOVE_ENABLED + value: {{ .move | quote }} + {{- end }} + {{- with .maxEditableFileSize }} + - name: PUBLIC_STACKABLE_COCKPIT_MAX_EDITABLE_FILE_SIZE + value: {{ . | int64 | quote }} + {{- end }} + {{- with .archivePreviewMaxMb }} + - name: STACKABLE_COCKPIT_ARCHIVE_PREVIEW_MAX_MB + value: {{ . | int64 | quote }} + {{- end }} + {{- with .parquetPreviewDisallowedCompressionTypes }} + - name: STACKABLE_COCKPIT_PARQUET_PREVIEW_DISALLOWED_COMPRESSION_TYPES + value: {{ . | quote }} + {{- end }} + {{- with .downloadHistoryRetentionDays }} + - name: STACKABLE_COCKPIT_DOWNLOAD_HISTORY_RETENTION_DAYS + value: {{ . | int64 | quote }} + {{- end }} {{- end }} {{- if .Values.trino.queryTtl }} - name: STACKABLE_COCKPIT_QUERY_TTL @@ -129,6 +178,44 @@ spec: name: {{ include "cockpit.fullname" . }} key: session-secret {{- end }} + - name: DATABASE_HOST + value: {{ required "database.host is required" .Values.database.host | quote }} + - name: DATABASE_PORT + value: {{ include "cockpit.databasePort" . | quote }} + - name: DATABASE_NAME + value: {{ .Values.database.name | quote }} + - name: DATABASE_USER + value: {{ .Values.database.user | quote }} + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ required "database.password.secretKeyRef.name is required" .Values.database.password.secretKeyRef.name | quote }} + key: {{ .Values.database.password.secretKeyRef.key | quote }} + {{- with .Values.database.tls.mode }} + - name: DATABASE_TLS_MODE + value: {{ . | quote }} + {{- end }} + {{- if .Values.database.tls.caCert }} + - name: DATABASE_TLS_CA_CERT + value: {{ .Values.database.tls.caCert | quote }} + {{- else if .Values.database.tls.secretClass }} + - name: DATABASE_TLS_CA_CERT + value: /stackable/database-tls/ca.crt + {{- end }} + {{- if eq (toString (index $features "storage" | default dict).enabled) "true" }} + - name: STORAGE_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: {{ required "storage.encryptionKey.secretKeyRef.name is required when features.storage.enabled is true" .Values.storage.encryptionKey.secretKeyRef.name | quote }} + key: {{ .Values.storage.encryptionKey.secretKeyRef.key | quote }} + {{- end }} + {{- if .Values.storage.tls.caCert }} + - name: STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT + value: {{ .Values.storage.tls.caCert | quote }} + {{- else if .Values.storage.tls.secretClass }} + - name: STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT + value: /stackable/storage-tls/ca.crt + {{- end }} {{- if .Values.auth.oidc.enabled }} - name: STACKABLE_COCKPIT_OIDC_CLIENT_SECRET valueFrom: @@ -230,9 +317,27 @@ spec: mountPath: /stackable/trino-tls readOnly: true {{- end }} + {{- if .Values.database.tls.secretClass }} + - name: database-tls + mountPath: /stackable/database-tls + readOnly: true + {{- end }} + {{- if .Values.storage.tls.secretClass }} + - name: storage-tls + mountPath: /stackable/storage-tls + readOnly: true + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} volumes: - name: tmp - emptyDir: {} + emptyDir: + {{- with .Values.tmp.sizeLimit }} + {{- /* A plain number arrives as float64 and would render as 1e+09. */}} + sizeLimit: {{ if kindIs "float64" . }}{{ int64 . }}{{ else }}{{ . | quote }}{{ end }} + {{- else }} {} + {{- end }} {{- if .Values.trino.tls.secretClass }} - name: trino-tls ephemeral: @@ -249,6 +354,41 @@ spec: requests: storage: 1 {{- end }} + {{- if .Values.database.tls.secretClass }} + - name: database-tls + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: {{ .Values.database.tls.secretClass | quote }} + secrets.stackable.tech/scope: pod + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1 + {{- end }} + {{- if .Values.storage.tls.secretClass }} + - name: storage-tls + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: {{ .Values.storage.tls.secretClass | quote }} + secrets.stackable.tech/scope: pod + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1 + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/deploy/helm/cockpit/values.yaml b/deploy/helm/cockpit/values.yaml index 4a701861..c7d4e5c5 100644 --- a/deploy/helm/cockpit/values.yaml +++ b/deploy/helm/cockpit/values.yaml @@ -111,8 +111,6 @@ terminationGracePeriodSeconds: 30 # storage: # # Show the S3/HDFS file browser and enable routes under /storage. Default: false (application default). # enabled: false -# # Reconnect to the most recently used storage connection on the storage page. Default: false (application default). -# autoConnect: false # # Comma-separated page size options for paginated list views. Default: "25,50,100" (application default). # pageSizes: "25,50,100" # # Page size selected when no user preference is stored. Must be included in pageSizes. Default: first page size (application default). @@ -121,7 +119,7 @@ terminationGracePeriodSeconds: 30 # maxRecentFiles: 15 # # Maximum parallel requests for file uploads. Default: 3 (application default). # uploadConcurrency: 3 -# # Maximum bytes fetched for text, CSV, and JSON previews. Default: 262144 (application default). +# # Maximum bytes fetched for text, TSV, and JSON previews; editable text files are fetched completely. Default: 262144 (application default). # textPreviewBytes: 262144 # # Maximum bytes fetched for image previews. Default: 5242880 (application default). # imagePreviewBytes: 5242880 @@ -131,10 +129,48 @@ terminationGracePeriodSeconds: 30 # filePreviewRows: 250 # # Maximum columns in tabular file previews. Default: 50 (application default). # filePreviewColumns: 50 +# # Objects copied at once within one copy, move or rename request. Default: 8 (application default). +# transferConcurrency: 8 +# # Restore open file browser tabs from the last session. Default: false (application default). +# restoreTabs: false +# # Load CSV and Parquet preview rows lazily while scrolling. Default: true (application default). +# infiniteScroll: true +# # Show Cut/Copy and allow dragging items. Client-only: the server work happens on paste or move. Default: false (application default). +# cutCopy: false +# # Show Paste; the transfer API accepts copies only with this, and moves with this or move. Default: false (application default). +# paste: false +# # Show Rename; the transfer API accepts renames. Default: false (application default). +# rename: false +# # Allow drag-and-drop move (dragging also needs cutCopy); the transfer API accepts moves with this or paste. Default: false (application default). +# move: false +# # Maximum size in bytes of files that can be edited and saved in the text editor. +# # config.bodySizeLimit must be at least this large. Default: 5242880 (application default). +# maxEditableFileSize: 5242880 +# # Maximum size in MB of an archive opened for preview, checked against its compressed size, +# # the total size of the listed entries and the size of an extracted entry. The archive cache +# # (config.archiveCacheMaxMb) is raised to at least twice this value. Default: 100 (application default). +# archivePreviewMaxMb: 100 +# # Comma-separated Parquet compression codecs that are not previewed. A "-no_offset" suffix +# # only blocks files without an offset index. Default: "gzip-no_offset" (application default). +# parquetPreviewDisallowedCompressionTypes: "gzip-no_offset" +# # Days a user's download history is kept. Integer from 1 to 3650. +# # Default: 30 (application default). +# downloadHistoryRetentionDays: 30 logging: level: "" +# Additional volumes and container mounts, e.g. for a CA certificate referenced by +# trino.tls.caCert, database.tls.caCert or storage.tls.caCert. +extraVolumes: [] + # - name: database-ca + # secret: + # secretName: database-ca +extraVolumeMounts: [] + # - name: database-ca + # mountPath: /stackable/database-ca + # readOnly: true + nodeSelector: {} tolerations: [] @@ -145,6 +181,19 @@ affinity: {} config: # Node.js environment nodeEnv: production + # Maximum request body size (BODY_SIZE_LIMIT), e.g. "12M". Empty uses the image default (6M). + # Must be at least the editable file size limit of the storage text editor (5 MiB by default). + bodySizeLimit: "" + # Maximum total size in MB of the archive preview cache in /tmp + # (STACKABLE_COCKPIT_ARCHIVE_CACHE_MAX_MB). Empty uses the application default (512). + # Keep it below tmp.sizeLimit. + archiveCacheMaxMb: "" + +# Scratch volume mounted at /tmp, used for the archive preview cache. +tmp: + # Size limit of the emptyDir. The kubelet evicts the pod when it is exceeded, + # so keep it above config.archiveCacheMaxMb (512 MB by default). Empty means no limit. + sizeLimit: 1Gi # Session secret for signing cookies. Chart auto-generates one if secretKeyRef.name is empty. sessionSecret: @@ -152,6 +201,52 @@ sessionSecret: name: "" key: session-secret +# PostgreSQL database (required). Stores sessions, users and storage connections. +# The chart does not deploy PostgreSQL; point it at an existing instance. +database: + host: "" + port: 5432 + name: cockpit + user: cockpit + # Required. + password: + secretKeyRef: + name: "" + key: database-password + tls: + # "verify-full" (verify certificate and host name), "require" (encrypt without + # verification) or "disable". Empty uses the application default: verify-full when + # config.nodeEnv is "production", otherwise disable. + mode: "" + # Path to a CA certificate file inside the container, e.g. mounted via extraVolumes. + # Requires mode verify-full. Mutually exclusive with secretClass. + caCert: "" + # Stackable SecretClass name for provisioning the database CA certificate. + # Requires mode verify-full. Mutually exclusive with caCert. + # The Secret Operator mounts ca.crt into /stackable/database-tls. + secretClass: "" + +# Storage browser settings that are not feature flags. +storage: + # Key that encrypts stored storage connection credentials: a 64-character hex string (32 bytes), + # e.g. from `openssl rand -hex 32`. Required when features.storage.enabled is true. + # Keep it stable: changing or losing it makes all stored credentials unreadable. + encryptionKey: + secretKeyRef: + name: "" + key: storage-encryption-key + tls: + # CA certificate for S3 connections with TLS verification. It replaces the system trust + # store, so S3 endpoints with publicly trusted certificates fail while it is set. + # Connections that disable verification ignore it. + # Path to a CA certificate file inside the container, e.g. mounted via extraVolumes. + # Mutually exclusive with secretClass. + caCert: "" + # Stackable SecretClass name for provisioning the S3 CA certificate. + # Mutually exclusive with caCert. + # The Secret Operator mounts ca.crt into /stackable/storage-tls. + secretClass: "" + # SvelteKit CSRF origin origin: "http://localhost:3000" @@ -204,11 +299,13 @@ trino: name: "" key: trino-auth-password tls: - # Skip TLS certificate verification + # Skip TLS certificate verification. Cannot be combined with caCert or secretClass. insecure: false - # Path to a custom CA certificate file (mounted via extraVolumes) + # Path to a CA certificate file inside the container, e.g. mounted via extraVolumes. + # Mutually exclusive with secretClass. caCert: "" # Stackable SecretClass name for provisioning the Trino CA certificate. + # Mutually exclusive with caCert. # The Secret Operator mounts ca.crt into /stackable/trino-tls. secretClass: "" # Query result TTL in seconds. Default: 1800 (application default). diff --git a/dev/lib/k8s.sh b/dev/lib/k8s.sh new file mode 100644 index 00000000..d659ebd6 --- /dev/null +++ b/dev/lib/k8s.sh @@ -0,0 +1,45 @@ +# shellcheck shell=bash +k8s::node_ip() { + local ip + ip=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}') + if [ -z "$ip" ]; then + log::error "Could not detect kind node IP." + fi + echo "$ip" +} + +k8s::wait_for_deployment() { + local name=$1 timeout=${2:-120} + kubectl wait --for=condition=available "deployment/$name" --timeout="${timeout}s" +} + +k8s::wait_for_statefulset() { + local name=$1 timeout=${2:-300} + kubectl rollout status "statefulset/$name" --timeout="${timeout}s" +} + +k8s::wait_for_pod() { + local label=$1 timeout=${2:-60} + kubectl wait --for=condition=ready pod -l "$label" --timeout="${timeout}s" +} + +k8s::get_pod_name() { + local label=$1 + kubectl get pod -l "$label" -o jsonpath='{.items[0].metadata.name}' +} + +k8s::get_node_port() { + local service=$1 + kubectl get svc "$service" -o jsonpath='{.spec.ports[0].nodePort}' +} + +k8s::template_and_apply() { + local file=$1 + shift + local sed_exprs=() + for pair in "$@"; do + local key="${pair%%=*}" val="${pair#*=}" + sed_exprs+=(-e "s/\${${key}}/${val}/g") + done + sed "${sed_exprs[@]}" "$file" | kubectl apply -f - +} diff --git a/dev/lib/logging.sh b/dev/lib/logging.sh new file mode 100644 index 00000000..69627eb6 --- /dev/null +++ b/dev/lib/logging.sh @@ -0,0 +1,17 @@ +# shellcheck shell=bash +log::info() { + echo " [INFO] $*" +} + +log::warn() { + echo " [WARN] $*" >&2 +} + +log::error() { + echo " [ERROR] $*" >&2 + exit 1 +} + +log::ok() { + echo " [OK] $*" +} diff --git a/dev/lib/probe.sh b/dev/lib/probe.sh new file mode 100644 index 00000000..679a7a26 --- /dev/null +++ b/dev/lib/probe.sh @@ -0,0 +1,43 @@ +# shellcheck shell=bash +probe::url() { + local port=$1 path=$2 + local scheme=${3:-http} + local timeout=${4:-120} + shift 4 + + local deadline url candidate + deadline=$(( $(date +%s) + timeout )) + url="" + while [ -z "$url" ] && [ "$(date +%s)" -lt "$deadline" ]; do + for candidate in "${scheme}://${NODE_IP}:${port}" "${scheme}://127.0.0.1:${port}" "${scheme}://localhost:${port}"; do + if curl -sf --max-time 2 "$@" "${candidate}${path}" >/dev/null 2>&1; then + url="$candidate" + break + fi + done + [ -z "$url" ] && sleep 2 + done + + if [ -z "$url" ]; then + return 1 + fi + echo "$url" +} + +probe::tcp_host() { + local port=$1 timeout=${2:-60} + local deadline host + deadline=$(( $(date +%s) + timeout )) + + while [ "$(date +%s)" -lt "$deadline" ]; do + for host in "$NODE_IP" 127.0.0.1 localhost; do + if timeout 2 bash -c "/dev/null 2>&1; then + echo "$host" + return 0 + fi + done + sleep 2 + done + + return 1 +} diff --git a/dev/modules/env.sh b/dev/modules/env.sh new file mode 100644 index 00000000..108d3e9e --- /dev/null +++ b/dev/modules/env.sh @@ -0,0 +1,86 @@ +# shellcheck shell=bash +env::write() { + log::info "Writing .env.development..." + + local session_secret encryption_key + session_secret=$(openssl rand -hex 32) + encryption_key=$(openssl rand -hex 32) + + if [ -f "$ENV_FILE" ]; then + log::info "Backing up existing .env.development to .env.development.bak" + cp "$ENV_FILE" "$ENV_FILE.bak" + fi + + if [[ "$SKIP_TRINO" == false ]]; then + cat > "$ENV_FILE" < "$ENV_FILE" </dev/null 2>&1; do + sleep 2 + done + + kcadm() { + kubectl exec "$pod" -- /opt/keycloak/bin/kcadm.sh "$@" + } + + if kcadm get realms/stackable --fields realm 2>/dev/null | grep -q '"stackable"'; then + log::info "Realm 'stackable' already exists, skipping Keycloak configuration." + local client_uuid + client_uuid=$(kcadm get clients -r stackable --fields id,clientId \ + | grep -B1 '"stackable-cockpit"' | grep '"id"' | sed 's/.*: *"\(.*\)".*/\1/') + local client_secret + client_secret=$(kcadm get clients/"$client_uuid"/client-secret -r stackable --fields value \ + | grep '"value"' | sed 's/.*: *"\(.*\)".*/\1/') + export OIDC_CLIENT_SECRET="$client_secret" + return + fi + + log::info "Logging into Keycloak admin CLI..." + kcadm config credentials \ + --server http://localhost:8080 \ + --realm master \ + --user admin \ + --password admin + + log::info "Creating realm 'stackable'..." + kcadm create realms -s realm=stackable -s enabled=true + + log::info "Creating client 'stackable-cockpit'..." + local client_uuid + client_uuid=$(kcadm create clients -r stackable \ + -s clientId=stackable-cockpit \ + -s enabled=true \ + -s protocol=openid-connect \ + -s publicClient=false \ + -s standardFlowEnabled=true \ + -s directAccessGrantsEnabled=false \ + -s 'redirectUris=["*"]' \ + -s 'webOrigins=["*"]' \ + -i) + + log::info "Creating client 'trino'..." + kcadm create clients -r stackable \ + -s clientId=trino \ + -s enabled=true \ + -s protocol=openid-connect \ + -s publicClient=false \ + -s standardFlowEnabled=true \ + -s directAccessGrantsEnabled=false \ + -s secret=trino-oidc-dev \ + -s 'redirectUris=["*"]' \ + -s 'webOrigins=["*"]' + + __keycloak_create_user alice alicealice Alice Example + __keycloak_create_user bob bobbob Bob Example + + log::info "Fetching client secret..." + local client_secret + client_secret=$(kcadm get clients/"$client_uuid"/client-secret -r stackable --fields value \ + | grep '"value"' | sed 's/.*: *"\(.*\)".*/\1/') + export OIDC_CLIENT_SECRET="$client_secret" +} + +__keycloak_create_user() { + local username=$1 password=$2 first=$3 last=$4 + log::info "Creating user '$username'..." + kcadm create users -r stackable \ + -s username="$username" \ + -s email="$username@example.com" \ + -s firstName="$first" \ + -s lastName="$last" \ + -s enabled=true + kcadm set-password -r stackable --username "$username" --new-password "$password" +} diff --git a/dev/modules/postgresql.sh b/dev/modules/postgresql.sh new file mode 100644 index 00000000..356ef637 --- /dev/null +++ b/dev/modules/postgresql.sh @@ -0,0 +1,39 @@ +# shellcheck shell=bash +postgresql::deploy() { + [[ "$SKIP_POSTGRESQL" == true ]] && return 0 + + log::info "Deploying PostgreSQL 18..." + helm upgrade --install postgresql "$SCRIPT_DIR/postgresql" \ + --namespace default \ + --wait \ + --timeout 60s + + # Helm does not recreate an unchanged workload deleted outside Helm. + # Applying the rendered chart restores it on subsequent development setup runs. + helm template postgresql "$SCRIPT_DIR/postgresql" \ + --namespace default | kubectl apply -f - +} + +postgresql::migrate() { + [[ "$SKIP_POSTGRESQL" == true ]] && return 0 + + log::info "Waiting for PostgreSQL to be ready..." + k8s::wait_for_pod app=postgresql 60 + + local postgresql_host + if ! postgresql_host=$(probe::tcp_host 31432 60); then + log::error "Could not reach PostgreSQL via NodePort 31432 within 60s." + fi + export POSTGRESQL_HOST="$postgresql_host" + + log::info "Running database migrations..." + ( + cd "$PROJECT_DIR" && + DATABASE_HOST="$POSTGRESQL_HOST" \ + DATABASE_PORT=31432 \ + DATABASE_NAME=cockpit \ + DATABASE_USER=cockpit \ + DATABASE_PASSWORD=cockpit-dev-password \ + npx tsx src/lib/server/migrate.ts + ) +} diff --git a/dev/modules/prerequisites.sh b/dev/modules/prerequisites.sh new file mode 100644 index 00000000..c7834764 --- /dev/null +++ b/dev/modules/prerequisites.sh @@ -0,0 +1,13 @@ +# shellcheck shell=bash +prerequisites::run() { + log::info "Installing npm dependencies..." + (cd "$PROJECT_DIR" && npm install) + + if [[ "$SKIP_TRINO" == true ]]; then + log::info "Installing Stackable operators (commons, listener, secret)..." + stackablectl operator install commons listener secret + else + log::info "Installing Stackable operators (commons, listener, secret, trino)..." + stackablectl operator install commons listener secret trino + fi +} diff --git a/dev/modules/secret.sh b/dev/modules/secret.sh new file mode 100644 index 00000000..714fa6af --- /dev/null +++ b/dev/modules/secret.sh @@ -0,0 +1,8 @@ +# shellcheck shell=bash +secret::create() { + log::info "Creating stackable-cockpit-credentials Secret..." + kubectl delete secret stackable-cockpit-credentials --ignore-not-found + kubectl create secret generic stackable-cockpit-credentials \ + --from-literal=oidc-client-secret="$OIDC_CLIENT_SECRET" \ + --from-literal=trino-auth-password=stackable-cockpit-dev +} diff --git a/dev/modules/summary.sh b/dev/modules/summary.sh new file mode 100644 index 00000000..6c08d1df --- /dev/null +++ b/dev/modules/summary.sh @@ -0,0 +1,36 @@ +# shellcheck shell=bash +summary::print() { + echo "" + log::ok "Setup complete" + echo "" + echo "Start the dev server with: npm run dev" + echo "" + echo "Keycloak: http://${NODE_IP}:30080" + echo " Admin: admin / admin" + echo "" + if [[ "$SKIP_TRINO" == false ]]; then + echo "Trino endpoint: https://${NODE_IP}:${TRINO_PORT}" + echo "" + echo "Trino connection is pre-configured via STACKABLE_COCKPIT_TRINO_* env vars." + echo "" + else + echo "Trino was skipped. Add STACKABLE_COCKPIT_TRINO_* vars to $ENV_FILE manually when ready." + echo "" + fi + if [[ "$SKIP_GARAGE" == false ]]; then + echo "Garage S3: http://${NODE_IP}:30900 (admin: http://${NODE_IP}:30902)" + echo " Credentials written to s3-config.json for E2E tests." + echo "" + fi + if [[ "$SKIP_POSTGRESQL" == false ]]; then + echo "PostgreSQL: ${POSTGRESQL_HOST:-$NODE_IP}:31432" + echo " Database: cockpit" + echo " User: cockpit" + echo " Password: cockpit-dev-password" + echo " Environment: DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD" + echo "" + fi + echo "Test users (OIDC):" + echo " alice / alicealice" + echo " bob / bobbob" +} diff --git a/dev/modules/trino.sh b/dev/modules/trino.sh new file mode 100644 index 00000000..03ff0f2a --- /dev/null +++ b/dev/modules/trino.sh @@ -0,0 +1,25 @@ +# shellcheck shell=bash +trino::deploy() { + [[ "$SKIP_TRINO" == true ]] && return 0 + + log::info "Deploying Trino..." + k8s::template_and_apply "$SCRIPT_DIR/trino.yaml" "NODE_IP=$NODE_IP" +} + +trino::probe() { + [[ "$SKIP_TRINO" == true ]] && return 0 + + TRINO_PORT=$(k8s::get_node_port trino-coordinator) + + local trino_url + trino_url=$(probe::url "$TRINO_PORT" /v1/info https 30 -sk || true) + export TRINO_BASE_URL="${trino_url:-https://${NODE_IP}:${TRINO_PORT}}" +} + +trino::wait_for_ready() { + [[ "$SKIP_TRINO" == true ]] && return 0 + + log::info "Waiting for Trino to be ready..." + k8s::wait_for_statefulset trino-coordinator-default 300 + log::info "Trino endpoint: https://${NODE_IP}:${TRINO_PORT}" +} diff --git a/dev/postgresql/Chart.yaml b/dev/postgresql/Chart.yaml new file mode 100644 index 00000000..156e12f1 --- /dev/null +++ b/dev/postgresql/Chart.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: v2 +name: postgresql +description: PostgreSQL 18 database for local dev and E2E testing +type: application +version: 0.1.0 +appVersion: '18' diff --git a/dev/postgresql/templates/configmap.yaml b/dev/postgresql/templates/configmap.yaml new file mode 100644 index 00000000..ef868f04 --- /dev/null +++ b/dev/postgresql/templates/configmap.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgresql-config + namespace: default +data: + postgresql.conf: |- + # PostgreSQL configuration for development + listen_addresses = '*' + max_connections = 100 + shared_buffers = 128MB + effective_cache_size = 256MB + work_mem = 4MB + pg_hba.conf: |- + # PostgreSQL Host-based Authentication + local all all trust + host all all 127.0.0.1/32 md5 + host all all ::1/128 md5 + host all all 0.0.0.0/0 md5 diff --git a/dev/postgresql/templates/deployment.yaml b/dev/postgresql/templates/deployment.yaml new file mode 100644 index 00000000..35f2729f --- /dev/null +++ b/dev/postgresql/templates/deployment.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgresql + namespace: default + labels: + app: postgresql +spec: + replicas: 1 + selector: + matchLabels: + app: postgresql + template: + metadata: + labels: + app: postgresql + spec: + containers: + - name: postgresql + image: '{{ .Values.image.repository }}:{{ .Values.image.tag }}' + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_DB + value: {{ .Values.database.name | quote }} + - name: POSTGRES_USER + value: {{ .Values.database.user | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgresql-secret + key: password + volumeMounts: + - name: data + mountPath: /var/lib/postgresql + - name: config + mountPath: /etc/postgresql + readOnly: true + readinessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U {{ .Values.database.user }} + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: data + emptyDir: {} + - name: config + configMap: + name: postgresql-config diff --git a/dev/postgresql/templates/secret.yaml b/dev/postgresql/templates/secret.yaml new file mode 100644 index 00000000..0e9bddc5 --- /dev/null +++ b/dev/postgresql/templates/secret.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgresql-secret + namespace: default +type: Opaque +data: + password: {{ .Values.database.password | b64enc | quote }} diff --git a/dev/postgresql/templates/service.yaml b/dev/postgresql/templates/service.yaml new file mode 100644 index 00000000..a2f4a6ea --- /dev/null +++ b/dev/postgresql/templates/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: postgresql + namespace: default +spec: + type: NodePort + selector: + app: postgresql + ports: + - name: postgres + port: 5432 + targetPort: 5432 + nodePort: {{ .Values.nodePorts.postgres }} diff --git a/dev/postgresql/values.yaml b/dev/postgresql/values.yaml new file mode 100644 index 00000000..241ccf70 --- /dev/null +++ b/dev/postgresql/values.yaml @@ -0,0 +1,16 @@ +--- +image: + repository: oci.stackable.tech/stackable/library/postgres + tag: '18.4-alpine3.24' + pullPolicy: IfNotPresent + +# Database configuration +database: + name: cockpit + user: cockpit + password: cockpit-dev-password + +# NodePort value for host access. +# PostgreSQL is exposed on nodePorts.postgres +nodePorts: + postgres: 31432 diff --git a/dev/setup.sh b/dev/setup.sh index 96ac2a1f..4f1897e5 100755 --- a/dev/setup.sh +++ b/dev/setup.sh @@ -3,356 +3,70 @@ # Assumes: kind cluster is running, kubectl context points to it. set -euo pipefail -SKIP_TRINO=false -SKIP_GARAGE=false +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$PROJECT_DIR/.env.development" +export ENV_FILE + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/logging.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/k8s.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/probe.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/modules/prerequisites.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/modules/keycloak.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/modules/trino.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/modules/garage.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/modules/postgresql.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/modules/env.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/modules/secret.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/modules/summary.sh" + +SKIP_TRINO=false; SKIP_GARAGE=false; SKIP_POSTGRESQL=false for arg in "$@"; do case "$arg" in --skip-trino) SKIP_TRINO=true ;; --skip-garage) SKIP_GARAGE=true ;; - *) echo "Unknown argument: $arg"; echo "Usage: $0 [--skip-trino] [--skip-garage]"; exit 1 ;; + --skip-postgresql) SKIP_POSTGRESQL=true ;; + *) echo "Unknown argument: $arg"; echo "Usage: $0 [--skip-trino] [--skip-garage] [--skip-postgresql]"; exit 1 ;; esac done +export SKIP_TRINO SKIP_GARAGE SKIP_POSTGRESQL -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -ENV_FILE="$PROJECT_DIR/.env.development" - -echo "=== Stackable Cockpit dev environment setup ===" -if [[ "$SKIP_TRINO" == true ]]; then - echo "(Trino deployment skipped via --skip-trino)" -fi -if [[ "$SKIP_GARAGE" == true ]]; then - echo "(Garage deployment skipped via --skip-garage)" -fi -echo "" - -# ------------------------------------------------------------------ -# 1. Install npm dependencies if needed -# ------------------------------------------------------------------ -if [ ! -d "$PROJECT_DIR/node_modules" ]; then - echo "Installing npm dependencies..." - (cd "$PROJECT_DIR" && npm install) -else - echo "npm dependencies already installed, skipping." -fi - -# ------------------------------------------------------------------ -# 2. Install Stackable operators -# ------------------------------------------------------------------ -echo "" -if [[ "$SKIP_TRINO" == true ]]; then - echo "Installing Stackable operators (commons, listener, secret)..." - stackablectl operator install commons listener secret -else - echo "Installing Stackable operators (commons, listener, secret, trino)..." - stackablectl operator install commons listener secret trino -fi - -# ------------------------------------------------------------------ -# 3. Deploy Keycloak -# ------------------------------------------------------------------ -echo "" -echo "Deploying Keycloak..." -kubectl apply -f "$SCRIPT_DIR/keycloak.yaml" - -# ------------------------------------------------------------------ -# 4. Wait for Keycloak and configure realm/client/users -# ------------------------------------------------------------------ +log::info "Stackable Cockpit dev environment setup" +[[ "$SKIP_TRINO" == true ]] && echo " (Trino deployment skipped)" +[[ "$SKIP_GARAGE" == true ]] && echo " (Garage deployment skipped)" +[[ "$SKIP_POSTGRESQL" == true ]] && echo " (PostgreSQL deployment skipped)" echo "" -echo "Waiting for Keycloak deployment to be available..." -kubectl wait --for=condition=available deployment/keycloak --timeout=120s - -POD=$(kubectl get pod -l app=keycloak -o jsonpath='{.items[0].metadata.name}') -NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}') -if [ -z "$NODE_IP" ]; then - echo "ERROR: Could not detect kind node IP." - exit 1 -fi -echo "Node IP: $NODE_IP" - -# ------------------------------------------------------------------ -# 5. Deploy Trino (after node IP is known, trino.yaml is a template) -# ------------------------------------------------------------------ -if [[ "$SKIP_TRINO" == false ]]; then - echo "" - echo "Deploying Trino..." - sed "s/\${NODE_IP}/$NODE_IP/g" "$SCRIPT_DIR/trino.yaml" | kubectl apply -f - -fi - -# ------------------------------------------------------------------ -# 5b. Deploy Garage S3 (via Helm) -# ------------------------------------------------------------------ -if [[ "$SKIP_GARAGE" == false ]]; then - echo "" - echo "Deploying Garage S3..." - helm upgrade --install garage "$SCRIPT_DIR/garage" \ - --namespace default \ - --wait \ - --timeout 60s -fi - -# On some local Kubernetes distributions (e.g. Rancher Desktop k3s), the node's -# InternalIP is not reachable from the host network, but NodePorts are exposed -# on localhost. Probe both and use the first reachable URL. -KEYCLOAK_BASE_URL="" -deadline=$(( $(date +%s) + 120 )) -while [ -z "$KEYCLOAK_BASE_URL" ] && [ "$(date +%s)" -lt "$deadline" ]; do - for base in "http://${NODE_IP}:30080" "http://127.0.0.1:30080" "http://localhost:30080"; do - if curl -sf --max-time 2 "${base}/realms/master" >/dev/null 2>&1; then - KEYCLOAK_BASE_URL="$base" - break - fi - done - [ -z "$KEYCLOAK_BASE_URL" ] && sleep 2 -done - -if [ -z "$KEYCLOAK_BASE_URL" ]; then - echo "ERROR: Could not reach Keycloak via NodePort 30080 within 120s." - echo "Tried: http://${NODE_IP}:30080, http://127.0.0.1:30080, http://localhost:30080" - exit 1 -fi -echo "Keycloak URL: ${KEYCLOAK_BASE_URL}" - -echo "Waiting for Keycloak to accept connections" -until curl -sf "${KEYCLOAK_BASE_URL}/realms/master" >/dev/null 2>&1; do - sleep 2 -done - -kcadm() { - kubectl exec "$POD" -- /opt/keycloak/bin/kcadm.sh "$@" -} - -# Check if realm already exists -if kcadm get realms/stackable --fields realm 2>/dev/null | grep -q '"stackable"'; then - echo "Realm 'stackable' already exists, skipping Keycloak configuration." - # Still need to fetch the client secret - CLIENT_UUID=$(kcadm get clients -r stackable --fields id,clientId \ - | grep -B1 '"stackable-cockpit"' | grep '"id"' | sed 's/.*: *"\(.*\)".*/\1/') - SECRET=$(kcadm get clients/"$CLIENT_UUID"/client-secret -r stackable --fields value \ - | grep '"value"' | sed 's/.*: *"\(.*\)".*/\1/') -else - echo "Logging into Keycloak admin CLI..." - kcadm config credentials \ - --server http://localhost:8080 \ - --realm master \ - --user admin \ - --password admin - - echo "Creating realm 'stackable'..." - kcadm create realms \ - -s realm=stackable \ - -s enabled=true - - echo "Creating client 'stackable-cockpit'..." - CLIENT_UUID=$(kcadm create clients \ - -r stackable \ - -s clientId=stackable-cockpit \ - -s enabled=true \ - -s protocol=openid-connect \ - -s publicClient=false \ - -s standardFlowEnabled=true \ - -s directAccessGrantsEnabled=false \ - -s 'redirectUris=["*"]' \ - -s 'webOrigins=["*"]' \ - -i) - echo "Creating client 'trino'..." - kcadm create clients \ - -r stackable \ - -s clientId=trino \ - -s enabled=true \ - -s protocol=openid-connect \ - -s publicClient=false \ - -s standardFlowEnabled=true \ - -s directAccessGrantsEnabled=false \ - -s secret=trino-oidc-dev \ - -s 'redirectUris=["*"]' \ - -s 'webOrigins=["*"]' - - create_user() { - local username=$1 password=$2 first=$3 last=$4 - echo "Creating user '$username'..." - kcadm create users \ - -r stackable \ - -s username="$username" \ - -s email="$username@example.com" \ - -s firstName="$first" \ - -s lastName="$last" \ - -s enabled=true - kcadm set-password \ - -r stackable \ - --username "$username" \ - --new-password "$password" - } - - create_user alice alicealice Alice Example - create_user bob bobbob Bob Example - - echo "Fetching client secret..." - SECRET=$(kcadm get clients/"$CLIENT_UUID"/client-secret -r stackable --fields value \ - | grep '"value"' | sed 's/.*: *"\(.*\)".*/\1/') -fi - -# ------------------------------------------------------------------ -# 7. Initialise Garage S3 (create bucket + access key, write s3-config.json) -# ------------------------------------------------------------------ -if [[ "$SKIP_GARAGE" == false ]]; then - echo "" - echo "Initialising Garage S3..." - - GARAGE_ADMIN_PORT=30902 - GARAGE_S3_PORT=30900 - GARAGE_BASE_URL="" - deadline=$(( $(date +%s) + 60 )) - while [ -z "$GARAGE_BASE_URL" ] && [ "$(date +%s)" -lt "$deadline" ]; do - for base in "http://${NODE_IP}:${GARAGE_ADMIN_PORT}" "http://127.0.0.1:${GARAGE_ADMIN_PORT}" "http://localhost:${GARAGE_ADMIN_PORT}"; do - if curl -sf --max-time 2 -H "Authorization: Bearer stackable-cockpit-e2e-admin-token" "${base}/v2/ListBuckets" >/dev/null 2>&1; then - GARAGE_BASE_URL="$base" - break - fi - done - [ -z "$GARAGE_BASE_URL" ] && sleep 2 - done - - if [ -z "$GARAGE_BASE_URL" ]; then - echo "ERROR: Could not reach Garage admin API via NodePort ${GARAGE_ADMIN_PORT} within 60s." - echo "Tried: http://${NODE_IP}:${GARAGE_ADMIN_PORT}, http://127.0.0.1:${GARAGE_ADMIN_PORT}, http://localhost:${GARAGE_ADMIN_PORT}" - exit 1 - fi - - # Derive the matching S3 base URL from the same host - GARAGE_HOST=$(echo "$GARAGE_BASE_URL" | sed 's|http://||; s|:[0-9]*$||') - GARAGE_S3_URL="http://${GARAGE_HOST}:${GARAGE_S3_PORT}" - - S3_SECRET_ACCESS_KEY=$(openssl rand -hex 32) \ - GARAGE_ADMIN_TOKEN=stackable-cockpit-e2e-admin-token \ - S3_ENDPOINT="$GARAGE_S3_URL" \ - GARAGE_ADMIN_URL="$GARAGE_BASE_URL" \ - S3_CONFIG_PATH="$PROJECT_DIR/s3-config.json" \ - "$SCRIPT_DIR/../e2e/init-garage-s3.sh" - - echo "Wrote s3-config.json (S3 endpoint: ${GARAGE_S3_URL})" -fi - -# ------------------------------------------------------------------ -# 8. Write .env.development -# ------------------------------------------------------------------ -echo "" -SESSION_SECRET=$(openssl rand -hex 32) - -if [ -f "$ENV_FILE" ]; then - echo "Backing up existing .env.development to .env.development.bak" - cp "$ENV_FILE" "$ENV_FILE.bak" -fi - -if [[ "$SKIP_TRINO" == false ]]; then - TRINO_PORT=$(kubectl get svc trino-coordinator -o jsonpath='{.spec.ports[0].nodePort}') - - # Probe Trino reachability the same way we did for Keycloak. - TRINO_BASE_URL="" - for base in "https://${NODE_IP}:${TRINO_PORT}" "https://127.0.0.1:${TRINO_PORT}" "https://localhost:${TRINO_PORT}"; do - if curl -sfk --max-time 2 "${base}/v1/info" >/dev/null 2>&1; then - TRINO_BASE_URL="$base" - break - fi - done - # Fall back to NODE_IP if none respond yet (Trino may still be starting). - TRINO_BASE_URL="${TRINO_BASE_URL:-https://${NODE_IP}:${TRINO_PORT}}" -fi - -if [[ "$SKIP_TRINO" == false ]]; then - cat > "$ENV_FILE" < "$ENV_FILE" < { + const log = locals.logger; + + try { + // Query example + const users = await db.query.users.findMany(); + log.info({ count: users.length }, 'Loaded users'); + return { users }; + } catch (error) { + log.error({ error }, 'Database query failed'); + throw error; + } +}; +``` + +### Connection Testing + +The database connection is tested automatically when the server starts. Check the logs for connection status: + +```bash +npm run dev # Check terminal output for "Database connection successful" +``` + +## Documentation + +- [Drizzle ORM Docs](https://orm.drizzle.team/docs/overview) +- [PostgreSQL Driver](https://orm.drizzle.team/docs/get-started-postgresql) +- [Query API](https://orm.drizzle.team/docs/select) + +## Troubleshooting + +### Connection Failed + +1. Ensure PostgreSQL is running: `kubectl get pod -l app=postgresql` +2. Check environment variables: `env | grep DATABASE_` +3. Verify the database exists: `psql -h localhost -U cockpit -d cockpit` +4. Check logs: `kubectl logs -l app=postgresql` + +### Schema Sync Issues + +If schema changes aren't reflected: + +```bash +npm run db:generate # Regenerate migrations +npm run db:migrate # Apply migrations +npm run dev # Restart dev server +``` + +### View Database State + +To inspect the current database state: + +```bash +npm run db:studio # Opens Drizzle Studio for visual inspection +``` + +Or use psql directly: + +```bash +psql -h localhost -U cockpit -d cockpit +# Once connected: +\dt # List all tables +\d # Describe a table +SELECT * FROM ; # View data +``` + +## Security + +### SSL/TLS Configuration + +`DATABASE_TLS_MODE` selects how the connection is secured: + +| Mode | Behaviour | +| ------------- | ------------------------------------------------------------------------------------ | +| `verify-full` | TLS with certificate and host name verification. Default in production. | +| `require` | TLS without certificate verification. Encrypts, but doesn't authenticate the server. | +| `disable` | No TLS. Default when `NODE_ENV` isn't `production`. | + +With `verify-full`, the certificate must chain to `DATABASE_TLS_CA_CERT` if it is set, otherwise to the CA certificates bundled with Node.js (the public Mozilla CAs, not the operating system's trust store). Set `DATABASE_TLS_CA_CERT` for any private CA, such as the one that signs AWS RDS certificates. It replaces the bundled CAs and is only accepted with `verify-full`; any other mode fails at startup. + +The same settings apply to the application runtime (`src/lib/server/db.ts`) and to `drizzle.config.ts` (migrations and Drizzle Studio). + +Only use `require` or `disable` in production when the network path to the database is otherwise protected. The application logs a warning when certificate verification is off in production. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..3617abbb --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'drizzle-kit'; +import { databaseSsl, databaseTlsMode } from './src/lib/server/db-tls'; + +const envFile = process.env.DRIZZLE_ENV_FILE ?? '.env.development'; +try { + process.loadEnvFile(envFile); +} catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; +} + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} must be set (load a file with DRIZZLE_ENV_FILE if needed)`); + return value; +} + +const isProduction = process.env.NODE_ENV === 'production'; + +export default defineConfig({ + schema: ['./src/lib/server/schema.ts', './src/lib/server/auth-schema.ts'], + out: './src/lib/server/migrations', + dialect: 'postgresql', + dbCredentials: { + host: required('DATABASE_HOST'), + port: Number(required('DATABASE_PORT')), + database: required('DATABASE_NAME'), + user: required('DATABASE_USER'), + password: required('DATABASE_PASSWORD'), + ssl: databaseSsl(process.env, databaseTlsMode(process.env, isProduction)) + } +}); diff --git a/e2e/auth/auth.setup.ts b/e2e/auth/auth.setup.ts deleted file mode 100644 index 748bcd9e..00000000 --- a/e2e/auth/auth.setup.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { test as setup, expect } from '@playwright/test'; -import path from 'path'; -import { waitForHydration } from '../support/helpers.js'; - -// Each Playwright project runs its own auth setup, producing a unique -// session. The mock OIDC server issues a different `sub` per token so -// parallel browser projects get isolated server-side state (e.g. the -// in-memory Trino connection store is keyed by userId). - -setup('authenticate via mock OIDC', async ({ page }, testInfo) => { - const authFile = path.join(import.meta.dirname, `../.auth/user-${testInfo.project.name}.json`); - - // Navigate to the app — auth guard redirects to /auth/login - await page.goto('/'); - await expect(page).toHaveURL(/\/auth\/login/); - - // Wait for hydration so the button's onclick handler is attached - await waitForHydration(page); - - // Click "Sign in with SSO" - await page.getByRole('button', { name: /sign in with sso/i }).click(); - - // Mock OIDC server auto-redirects — no form interaction needed - await expect(page).toHaveURL('/'); - await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); - - // Save authenticated storage state - await page.context().storageState({ path: authFile }); -}); diff --git a/e2e/auth/auth.spec.ts b/e2e/auth/auth.spec.ts index 34a2025c..0e81b6c2 100644 --- a/e2e/auth/auth.spec.ts +++ b/e2e/auth/auth.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { waitForHydration } from '../support/helpers.js'; test.describe('Authentication', () => { diff --git a/e2e/database/db.spec.ts b/e2e/database/db.spec.ts new file mode 100644 index 00000000..6885ab29 --- /dev/null +++ b/e2e/database/db.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '../support/fixtures.js'; +import { Client } from 'pg'; + +test.describe('Database connectivity', () => { + test('connects to the database and reports the database as healthy', async () => { + const client = new Client({ + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT ?? '5432', 10), + database: process.env.DATABASE_NAME, + user: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + ssl: false + }); + + await client.connect(); + try { + await client.query('SELECT 1 FROM user_storage_connections LIMIT 0'); + } finally { + await client.end(); + } + + expect(true).toBe(true); + }); +}); diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts index ec558fd5..4f58d44b 100644 --- a/e2e/i18n.spec.ts +++ b/e2e/i18n.spec.ts @@ -1,11 +1,9 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/fixtures.js'; import fs from 'fs'; -import path from 'path'; import { waitForHydration } from './support/helpers'; /** Load the saved auth storage state, optionally stripping the locale cookie. */ -function loadAuthState(projectName: string, { withoutLocale = false } = {}) { - const authFile = path.join(import.meta.dirname, `.auth/user-setup-${projectName}.json`); +function loadAuthState(authFile: string, { withoutLocale = false } = {}) { // eslint-disable-next-line security/detect-non-literal-fs-filename const state = JSON.parse(fs.readFileSync(authFile, 'utf-8')); if (withoutLocale) { @@ -17,9 +15,18 @@ function loadAuthState(projectName: string, { withoutLocale = false } = {}) { test.describe('Internationalisation', () => { test.use({ locale: 'en-US' }); + // Firefox is slower to hydrate and navigate; triple the default timeout for + // all tests in this block so they don't time out on slow CI runners. + test.beforeEach(() => { + test.slow(); + }); + test('renders in English by default with lang="en"', async ({ page }) => { await page.goto('/'); + // Wait for hydration so reactive state has settled before checking content. + await waitForHydration(page); + const html = page.locator('html'); await expect(html).toHaveAttribute('lang', 'en'); @@ -89,12 +96,15 @@ test.describe('Internationalisation', () => { await expect(page.getByText('Willkommen zurück')).toBeVisible(); }); - test('Accept-Language header respected for first visit', async ({ browser }, testInfo) => { + test('Accept-Language header respected for first visit', async ({ + browser, + workerStorageState + }) => { // Create a context with German Accept-Language but no locale cookie, // so paraglide falls back to the Accept-Language header const context = await browser.newContext({ locale: 'de-DE', - storageState: loadAuthState(testInfo.project.name, { withoutLocale: true }) + storageState: loadAuthState(workerStorageState, { withoutLocale: true }) }); const page = await context.newPage(); diff --git a/e2e/init-garage-s3.sh b/e2e/init-garage-s3.sh index fd5ac81a..10c7105c 100755 --- a/e2e/init-garage-s3.sh +++ b/e2e/init-garage-s3.sh @@ -43,6 +43,22 @@ json_find_bucket_id_by_alias() { ' "$bucket_name" } +json_read_field_from_file() { + local file="$1" + local field="$2" + + node -e ' + const fs = require("fs"); + try { + const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const value = process.argv[2].split(".").reduce((current, key) => current?.[key], data); + if (value !== undefined && value !== null) { + process.stdout.write(String(value)); + } + } catch { /* file missing or invalid JSON — emit nothing */ } + ' "$file" "$field" +} + admin_post() { local path="$1" local payload="$2" @@ -75,6 +91,19 @@ create_bucket() { admin_post '/v2/CreateBucket' "{\"globalAlias\":\"$bucket_name\"}" } +# Creates a bucket with no global alias so it does not appear in S3 ListBuckets. +create_hidden_bucket() { + admin_post '/v2/CreateBucket' '{}' +} + +bucket_exists() { + local bucket_id="$1" + + curl -fsS \ + -H "Authorization: Bearer $GARAGE_ADMIN_TOKEN" \ + "$GARAGE_ADMIN_URL/v2/GetBucketInfo?id=$bucket_id" >/dev/null 2>&1 +} + allow_bucket_key() { local bucket_id="$1" local access_key_id="$2" @@ -93,6 +122,109 @@ fi allow_bucket_key "$bucket_id" "$S3_ACCESS_KEY_ID" >/dev/null +# Hidden bucket: no global alias so it is absent from S3 ListBuckets responses, +# but the access key retains read and write (not owner) access. +# Idempotent: reuse the bucket ID stored in an existing config file if the +# bucket is still present in Garage; otherwise create a new one. +hidden_bucket_id=$(json_read_field_from_file "$S3_CONFIG_PATH" 'hiddenBucketId') +if [[ -z "$hidden_bucket_id" ]] || ! bucket_exists "$hidden_bucket_id"; then + hidden_bucket_response=$(create_hidden_bucket) + hidden_bucket_id=$(printf '%s' "$hidden_bucket_response" | json_get 'id') +fi + +admin_post '/v2/AllowBucketKey' \ + "{\"bucketId\":\"$hidden_bucket_id\",\"accessKeyId\":\"$S3_ACCESS_KEY_ID\",\"permissions\":{\"owner\":false,\"read\":true,\"write\":true}}" >/dev/null +# ────────────────────────────────────────────── +# Bucket enrichment: tags, lifecycle rules, sample data +# ────────────────────────────────────────────── +echo "Enriching bucket with tags, lifecycle rules, and sample objects..." + +# Export S3 env vars so the Node.js process can read them +export S3_ENDPOINT S3_REGION S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET + +node -e ' +const { + S3Client, + PutBucketLifecycleConfigurationCommand, + PutObjectCommand +} = require("@aws-sdk/client-s3"); + +const client = new S3Client({ + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION || "garage", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + }, + forcePathStyle: true, +}); + +const bucket = process.env.S3_BUCKET; + +async function enrich() { + // Lifecycle rules (Garage supports a subset of the S3 lifecycle API) + // Note: Garage does not implement PutBucketTagging (returns 501 Not Implemented). + try { + await client.send(new PutBucketLifecycleConfigurationCommand({ + Bucket: bucket, + LifecycleConfiguration: { + Rules: [ + { + ID: "expire-old-logs", + Status: "Enabled", + Filter: { Prefix: "logs/" }, + Expiration: { Days: 90 }, + }, + { + ID: "clean-aborted-uploads", + Status: "Enabled", + Filter: {}, + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 7 }, + }, + { + ID: "expire-deleted-markers", + Status: "Enabled", + Filter: {}, + NoncurrentVersionExpiration: { NoncurrentDays: 30 }, + }, + ], + }, + })); + console.log(" ✓ Lifecycle rules set"); + } catch (err) { + console.log(" ✗ Lifecycle rules failed:", err.message); + } + + // Sample objects + const samples = [ + { key: "logs/access.log", body: "192.168.1.1 GET /api/v1/query 200 1234\n10.0.0.1 POST /api/v1/run 201 56\n" }, + { key: "logs/error.log", body: "2026-07-09 ERROR: Connection timeout to trino-worker-3\n2026-07-09 WARN: Retry attempt 2/5\n" }, + { key: "archive/2024/transactions.csv", body: "id,amount,currency,date\nTX-001,150.00,USD,2024-01-15\nTX-002,275.50,EUR,2024-03-22\nTX-003,89.99,GBP,2024-06-01\n" }, + { key: "archive/2024/audit.log", body: "[2024-01-01] System initialized\n[2024-06-30] Scheduled maintenance completed\n" }, + { key: "README.md", body: "# Test Bucket\n\nThis bucket is used for E2E testing of the Stackable Cockpit.\n" }, + { key: "config/cluster.yaml", body: "cluster:\n name: e2e-test\n replicas: 3\n storage: 100Gi\n" }, + ]; + + for (const { key, body } of samples) { + try { + await client.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + })); + console.log(" ✓ Uploaded:", key); + } catch (err) { + console.log(" ✗ Upload failed:", key, err.message); + } + } +} + +enrich().catch((err) => { + console.error("Fatal error during bucket enrichment:", err); + process.exit(1); +}); +' + cat > "$S3_CONFIG_PATH" < "$S3_CONFIG_PATH" < { + test.use({ locale: 'en-US' }); + + test('navigating between apps shows a loading indicator until the page is ready', async ({ + page + }) => { + await page.goto('/'); + await waitForHydration(page); + + // Delay the Trino route's data fetch so the loading indicator stays on + // screen long enough to assert on it. + await page.route('**/trino/__data.json*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + await route.continue(); + }); + + await page.getByRole('link', { name: 'Trino', exact: true }).click(); + + const progress = page.locator('[data-navigation-progress]'); + await expect(progress).toBeVisible(); + await expect(progress.getByText('Loading…')).toBeVisible(); + + await expect(page.getByRole('heading', { name: 'Trino', exact: true })).toBeVisible(); + await expect(progress).toBeHidden(); + }); + + test('navigating within an app does not show the global loading bar', async ({ page }) => { + await page.goto('/storage'); + await waitForHydration(page); + + // Delay the connections page's data fetch. Even while it is in flight the + // global progress bar must stay hidden: it only appears between apps. + await page.route('**/settings/connections/__data.json*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + await route.continue(); + }); + + await page.getByRole('link', { name: 'Manage connections' }).click(); + + const progress = page.locator('[data-navigation-progress]'); + await expect(progress).toBeHidden(); + // Wait out the artificial delay while the intra-app navigation is in flight. + await page.waitForTimeout(2000); + await expect(progress).toHaveCount(0); + + await expect(page.getByRole('heading', { name: 'Manage connections' })).toBeVisible(); + }); + + test('sidebar highlights the active app when switching between dashboard, Trino and storage', async ({ + page + }) => { + await page.goto('/'); + await waitForHydration(page); + + const dashboardLink = page.getByRole('link', { name: 'Dashboard' }); + const trinoLink = page.getByRole('link', { name: 'Trino', exact: true }); + const storageLink = page.getByRole('link', { name: 'Storage' }); + + await expect(dashboardLink).toHaveAttribute('aria-current', 'page'); + + await trinoLink.click(); + await expect(page.getByRole('heading', { name: 'Trino', exact: true })).toBeVisible(); + await expect(trinoLink).toHaveAttribute('aria-current', 'page'); + await expect(dashboardLink).not.toHaveAttribute('aria-current', 'page'); + + await storageLink.click(); + await expect(page.getByRole('heading', { name: 'Storage', exact: true })).toBeVisible(); + await expect(storageLink).toHaveAttribute('aria-current', 'page'); + await expect(trinoLink).not.toHaveAttribute('aria-current', 'page'); + + await dashboardLink.click(); + await expect(page.getByRole('heading', { name: 'Dashboard', exact: true })).toBeVisible(); + await expect(dashboardLink).toHaveAttribute('aria-current', 'page'); + await expect(storageLink).not.toHaveAttribute('aria-current', 'page'); + }); +}); diff --git a/e2e/run-garage-tests.sh b/e2e/run-garage-tests.sh deleted file mode 100755 index d5683b22..00000000 --- a/e2e/run-garage-tests.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -S3_CONFIG_PATH="$ROOT_DIR/s3-config.json" - -if [[ ! -f "$S3_CONFIG_PATH" ]]; then - echo 's3-config.json was not found.' >&2 - echo 'Run dev/setup.sh to deploy Garage to your kind cluster (it writes s3-config.json).' >&2 - exit 1 -fi - -node --env-file=.env.test node_modules/.bin/vite build -# Run non-storage tests with full parallelism. -node_modules/.bin/playwright test e2e/auth/ e2e/trino/ e2e/smoke.spec.ts e2e/i18n.spec.ts "$@" -# Storage tests share a server-side S3 session per user and cannot run in -# parallel within the same browser project. Run them serially. -node_modules/.bin/playwright test e2e/storage/ --workers=1 "$@" diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 271256ef..b3c872bd 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,12 +1,19 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/fixtures.js'; import { waitForHydration } from './support/helpers'; test.describe('Smoke tests', () => { test.use({ locale: 'en-US' }); test('home page loads with app shell', async ({ page }) => { + // Firefox is slower to hydrate; triple the default timeout. + test.slow(); + await page.goto('/'); + // Wait for hydration so reactive state (page.url.pathname) has settled + // before checking aria-current, which depends on it. + await waitForHydration(page); + // Sidebar brand is visible await expect(page.getByText('Stackable', { exact: true })).toBeVisible(); @@ -28,6 +35,8 @@ test.describe('Smoke tests', () => { }); test('theme toggle switches between light and dark', async ({ page }) => { + // Firefox is slower to hydrate; triple the default timeout. + test.slow(); await page.goto('/'); const html = page.locator('html'); diff --git a/e2e/storage-s3.spec.ts b/e2e/storage-s3.spec.ts index 653cf5b0..4605c04c 100644 --- a/e2e/storage-s3.spec.ts +++ b/e2e/storage-s3.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/fixtures.js'; import { waitForHydration } from './support/helpers'; declare const process: { @@ -19,20 +19,38 @@ test.describe('Storage S3 (Garage)', () => { test.use({ locale: 'en-US' }); async function openConnectForm(page: import('@playwright/test').Page) { - await page.goto('/storage?disconnected=1'); + await page.goto('/storage'); await waitForHydration(page); const disconnectButton = page.getByRole('button', { name: 'Disconnect' }); if (await disconnectButton.isVisible().catch(() => false)) { await disconnectButton.click(); - // Disconnect now opens a confirmation modal — confirm it if it appears. - const confirmButton = page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }); - if (await confirmButton.isVisible({ timeout: 2_000 }).catch(() => false)) { - await confirmButton.click(); - } + await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click(); } await expect(page.getByRole('heading', { name: 'Connect to storage' })).toBeVisible(); + + // Clear all saved connections so tests start from a clean state. + const savedList = page.getByRole('list', { name: 'Saved connections' }); + while (await savedList.isVisible().catch(() => false)) { + const items = savedList.getByRole('listitem'); + if ((await items.count()) === 0) break; + if ( + await items + .first() + .filter({ hasText: 'No saved connections yet' }) + .isVisible() + .catch(() => false) + ) + break; + await items.first().getByRole('button').last().click({ force: true }); + const deleteMenuItem = page.getByRole('menuitem', { name: 'Delete', exact: true }); + if (await deleteMenuItem.isVisible().catch(() => false)) { + await deleteMenuItem.click(); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + } + await waitForHydration(page); + } } test('connects to Garage S3 bucket and lists buckets', async ({ page }) => { @@ -60,16 +78,15 @@ test.describe('Storage S3 (Garage)', () => { await page.getByLabel('Access key').fill(accessKeyId); await page.getByLabel('Secret key').fill(secretAccessKey); - await page.getByRole('button', { name: 'Connect' }).click(); + await page.getByRole('button', { name: 'Connect', exact: true }).click(); // After a successful connection the app redirects to /storage and shows the bucket list await expect(page).toHaveURL('/storage'); const main = page.locator('main'); await expect(main.getByRole('heading', { name: 'Buckets' })).toBeVisible(); - // The bucket created during Garage setup must appear in the list. - // Both the sidebar nav and the bucket grid render a link — use first() to - // avoid a strict-mode violation. + // The bucket created during Garage setup must appear in the grid + // (use .first() because the sidebar nav also renders a link to each bucket) await expect(main.getByRole('link', { name: bucket, exact: true }).first()).toBeVisible(); }); @@ -95,11 +112,12 @@ test.describe('Storage S3 (Garage)', () => { await page.getByLabel('Region').fill(region); await page.getByLabel('Access key').fill(accessKeyId); await page.getByLabel('Secret key').fill(secretAccessKey); - await page.getByRole('button', { name: 'Connect' }).click(); + await page.getByRole('button', { name: 'Connect', exact: true }).click(); await expect(page.locator('main').getByRole('heading', { name: 'Buckets' })).toBeVisible(); // Then disconnect — clicking Disconnect opens a confirmation modal. await page.getByRole('button', { name: 'Disconnect' }).click(); + // Disconnect now shows a confirmation modal; confirm it await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click(); // Should return to the connect form diff --git a/e2e/storage/add-bucket.spec.ts b/e2e/storage/add-bucket.spec.ts new file mode 100644 index 00000000..6b264ae1 --- /dev/null +++ b/e2e/storage/add-bucket.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createGarageBucketCredentials, + hasGarageAdmin, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { connectToStorage, modalBox, uniqueBucketName } from './helpers.js'; + +test.describe('Storage S3 — Add bucket manually', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + async function openAddBucketModal(page: import('@playwright/test').Page) { + await page.getByRole('button', { name: 'Add bucket' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Connect to a bucket' })).toBeVisible(); + } + + test('opens and closes the Add bucket modal', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + await openAddBucketModal(page); + + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('Connect button is disabled when input is empty', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + + await openAddBucketModal(page); + + await expect(modalBox(page).getByRole('button', { name: 'Connect' })).toBeDisabled(); + }); + + test('navigates into bucket after successful connection', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + await openAddBucketModal(page); + await modalBox(page).getByLabel('Bucket name').fill(credentials.bucket); + await modalBox(page).getByRole('button', { name: 'Connect' }).click(); + + await expect(page).toHaveURL( + `/storage/browse/${encodeURIComponent(new URL(credentials.endpoint).hostname)}/${encodeURIComponent(credentials.bucket)}` + ); + }); + + test('bucket appears in the bucket grid after successful connection', async ({ + page + }, testInfo) => { + test.skip( + !hasGarageAdmin(), + 'Skipped: Garage admin API is unavailable for per-test bucket setup' + ); + + const baseCredentials = requireGarageCredentials(); + const extraBucketName = uniqueBucketName(testInfo, 'manual-add'); + + // Connect before the extra bucket is created so it is absent from the initial listing. + // In Garage, AllowBucketKey with any permission causes the bucket to appear in ListBuckets, + // so we must create the bucket *after* the page has loaded to ensure it is not pre-listed. + await connectToStorage(page, baseCredentials); + await expect(page).toHaveURL('/storage'); + + // Create the extra bucket and grant the base key access now that the page is already loaded. + await createGarageBucketCredentials(baseCredentials, { + bucketName: extraBucketName, + keyName: `manual-${testInfo.project.name}-${crypto.randomUUID()}`, + permissions: { owner: false, read: true, write: false }, + ownerAccessKeyId: baseCredentials.accessKeyId + }); + + // The extra bucket should not be listed — it was created after the page loaded + const bucketLink = page.getByRole('link', { name: extraBucketName, exact: true }); + await expect(bucketLink.first()).not.toBeVisible(); + + // Add it manually + await openAddBucketModal(page); + await modalBox(page).getByLabel('Bucket name').fill(extraBucketName); + await modalBox(page).getByRole('button', { name: 'Connect' }).click(); + + // Should navigate into the bucket + await expect(page).toHaveURL( + `/storage/browse/${encodeURIComponent(new URL(baseCredentials.endpoint).hostname)}/${encodeURIComponent(extraBucketName)}` + ); + + // Go back to the bucket grid and confirm the bucket is now shown + await page.goto('/storage'); + await expect(bucketLink.first()).toBeVisible(); + }); + + test('shows access denied error for a bucket without read permission', async ({ + page + }, testInfo) => { + test.skip( + !hasGarageAdmin(), + 'Skipped: Garage admin API is unavailable for per-test bucket setup' + ); + + const baseCredentials = requireGarageCredentials(); + const blockedBucket = uniqueBucketName(testInfo, 'no-access'); + const noAccessCredentials = await createGarageBucketCredentials(baseCredentials, { + bucketName: blockedBucket, + keyName: `noaccess-${testInfo.project.name}-${crypto.randomUUID()}`, + permissions: { owner: false, read: false, write: false }, + ownerAccessKeyId: baseCredentials.accessKeyId + }); + + await connectToStorage(page, noAccessCredentials); + await expect(page).toHaveURL('/storage'); + + await openAddBucketModal(page); + await modalBox(page).getByLabel('Bucket name').fill(blockedBucket); + await modalBox(page).getByRole('button', { name: 'Connect' }).click(); + + await expect(modalBox(page).getByRole('alert')).toContainText('Access denied'); + + // Modal should remain open + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page).toHaveURL('/storage'); + }); + + test('shows not found error for a non-existent bucket', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + await openAddBucketModal(page); + await modalBox(page).getByLabel('Bucket name').fill('this-bucket-does-not-exist-xyz-99999'); + await modalBox(page).getByRole('button', { name: 'Connect' }).click(); + + await expect(modalBox(page).getByRole('alert')).toContainText('Bucket not found'); + + // Modal should remain open + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page).toHaveURL('/storage'); + }); + + test('resets form state after closing and reopening the modal', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + + await openAddBucketModal(page); + await modalBox(page).getByLabel('Bucket name').fill('some-value'); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await openAddBucketModal(page); + await expect(modalBox(page).getByLabel('Bucket name')).toHaveValue(''); + }); +}); diff --git a/e2e/storage/archive.spec.ts b/e2e/storage/archive.spec.ts new file mode 100644 index 00000000..b7935d4a --- /dev/null +++ b/e2e/storage/archive.spec.ts @@ -0,0 +1,472 @@ +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { test, expect } from '../support/fixtures.js'; +import AdmZip from 'adm-zip'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + connectAndOpenPrefix, + deleteKnownKeys, + rowByName, + uniquePrefix, + waitForObjectsLoaded +} from './helpers.js'; + +function makeZip(entries: Record): Buffer { + const zip = new AdmZip(); + for (const [name, content] of Object.entries(entries)) { + zip.addFile(name, Buffer.from(content, 'utf-8')); + } + return Buffer.from(zip.toBuffer()); +} + +test.describe('Storage S3 — Archive preview', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('opens archive with generic content type and shows its contents', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-open'); + const zipBuffer = makeZip({ + 'README.md': '# Archive test', + 'data/notes.txt': 'some notes', + 'data/results.csv': 'a,b,c\n1,2,3' + }); + const cleanupKeys = [`${prefix}archive.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.zip`, + Body: zipBuffer, + ContentType: 'application/octet-stream' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Double-click the archive file + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + + // Should see archive contents — top-level folder and file + await expect(rowByName(page, 'README.md')).toBeVisible(); + await expect(rowByName(page, 'data')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('shows the unsupported preview and download option for RAR archives', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-rar'); + const cleanupKeys = [`${prefix}archive.rar`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.rar`, + Body: Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]), + ContentType: 'binary/octet-stream' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'archive.rar').dblclick(); + await expect(page.getByRole('heading', { name: 'archive.rar' })).toBeVisible(); + await expect(page.getByText('RAR archives are not supported.')).toBeVisible(); + await expect(page.getByRole('button', { name: /download full/i })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('navigates into directories within archive', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-navigate'); + const zipBuffer = makeZip({ + 'a/b/c/file.txt': 'deep', + 'a/b/other.txt': 'other', + 'a/root.txt': 'root' + }); + const cleanupKeys = [`${prefix}archive.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.zip`, + Body: zipBuffer, + ContentType: 'application/zip' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + + // Navigate into a/ + await rowByName(page, 'a').click(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'root.txt')).toBeVisible(); + await expect(rowByName(page, 'b')).toBeVisible(); + + // Navigate into b/ + await rowByName(page, 'b').click(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'other.txt')).toBeVisible(); + await expect(rowByName(page, 'c')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('previews text file inside archive', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-preview'); + const zipBuffer = makeZip({ + 'hello.txt': 'Hello from inside archive!' + }); + const cleanupKeys = [`${prefix}archive.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.zip`, + Body: zipBuffer, + ContentType: 'application/zip' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + + // Double-click the text file inside archive + await rowByName(page, 'hello.txt').dblclick(); + + // Preview modal should show the file content + await expect(page.getByRole('heading', { name: 'hello.txt' })).toBeVisible(); + await expect(page.getByText('Hello from inside archive!')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('exits archive via containing-folder breadcrumb and requests fresh data', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-exit-same-location'); + const cleanupKeys = [`${prefix}archive.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.zip`, + Body: makeZip({ 'file.txt': 'content' }), + ContentType: 'application/octet-stream' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + const originalUrl = page.url(); + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'file.txt')).toBeVisible(); + + const listingResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === '/api/storage/list' && url.searchParams.get('prefix') === prefix; + }); + const folderName = prefix.split('/').filter(Boolean).at(-1)!; + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: folderName, exact: true }) + .click(); + expect((await listingResponse).ok()).toBe(true); + await waitForObjectsLoaded(page); + await expect(page).toHaveURL((url) => url.pathname === new URL(originalUrl).pathname); + await expect(rowByName(page, 'archive.zip')).toBeVisible(); + await expect(rowByName(page, 'file.txt')).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('shows the new contents after the archive is overwritten', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-overwritten'); + const key = `${prefix}archive.zip`; + const putArchive = (entries: Record) => + client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: key, + Body: makeZip(entries), + ContentType: 'application/octet-stream' + }) + ); + const folderName = prefix.split('/').filter(Boolean).at(-1)!; + + try { + await putArchive({ 'old.txt': 'old content' }); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'old.txt')).toBeVisible(); + + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: folderName, exact: true }) + .click(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'archive.zip')).toBeVisible(); + + // Overwrite the archive while its old version is still cached server-side. + await putArchive({ 'new.txt': 'new content' }); + + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'new.txt')).toBeVisible(); + await expect(rowByName(page, 'old.txt')).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [key]); + } + }); + + test('exits archive via breadcrumb bucket click', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-exit'); + const zipBuffer = makeZip({ 'file.txt': 'content' }); + const cleanupKeys = [`${prefix}archive.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.zip`, + Body: zipBuffer, + ContentType: 'application/zip' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'file.txt')).toBeVisible(); + + // Click bucket name in breadcrumb to exit archive + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: credentials.bucket, exact: true }) + .click(); + await waitForObjectsLoaded(page); + + // Should be at the bucket root (archive exited) + + await expect(page).toHaveURL((url) => { + const endpoint = new URL(credentials.endpoint); + return url.pathname === `/storage/browse/${endpoint.hostname}/${credentials.bucket}`; + }); + await expect(rowByName(page, 'archive.zip')).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('exits archive via parent directory row', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-exit-parent'); + const zipBuffer = makeZip({ 'inner/file.txt': 'content' }); + const cleanupKeys = [`${prefix}archive.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.zip`, + Body: zipBuffer, + ContentType: 'application/zip' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + + // Navigate into inner/ + await rowByName(page, 'inner').click(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'file.txt')).toBeVisible(); + + // Click parent directory ... + await rowByName(page, '...').click(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'inner')).toBeVisible(); + + // Click ... again at archive root to exit + await rowByName(page, '...').click(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'archive.zip')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('opens nested archive inside another archive', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-nested'); + + // Create inner zip + const innerZip = new AdmZip(); + innerZip.addFile('nested.txt', Buffer.from('nested content', 'utf-8')); + const innerBuf = Buffer.from(innerZip.toBuffer()); + + // Create outer zip containing the inner zip + const outerZip = new AdmZip(); + outerZip.addFile('outer.txt', Buffer.from('outer content', 'utf-8')); + outerZip.addFile('inner_archive.zip', innerBuf); + const outerBuf = Buffer.from(outerZip.toBuffer()); + + const cleanupKeys = [`${prefix}outer.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}outer.zip`, + Body: outerBuf, + ContentType: 'application/zip' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'outer.zip').dblclick(); + await waitForObjectsLoaded(page); + + // Should see outer archive contents + await expect(rowByName(page, 'outer.txt')).toBeVisible(); + await expect(rowByName(page, 'inner_archive.zip')).toBeVisible(); + + // Double-click the nested archive to enter it + await rowByName(page, 'inner_archive.zip').dblclick(); + await waitForObjectsLoaded(page); + + // Should see nested archive contents + await expect(rowByName(page, 'nested.txt')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('shows archive breadcrumb and navigates back to archive root', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-breadcrumb'); + const zipBuffer = makeZip({ + 'sub/readme.txt': 'hello', + 'sub/deep/doc.md': '# doc' + }); + const cleanupKeys = [`${prefix}archive.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.zip`, + Body: zipBuffer, + ContentType: 'application/zip' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + + // Navigate into sub/ + await rowByName(page, 'sub').click(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'readme.txt')).toBeVisible(); + + // Navigate into deep/ + await rowByName(page, 'deep').click(); + await waitForObjectsLoaded(page); + await expect(rowByName(page, 'doc.md')).toBeVisible(); + + // Click archive name in breadcrumb to go back to archive root + await page + .getByRole('navigation', { name: 'Breadcrumb' }) + .getByRole('button', { name: 'archive.zip', exact: true }) + .click(); + await waitForObjectsLoaded(page); + + // Should see archive root contents + await expect(rowByName(page, 'sub')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('previews image file inside archive', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'archive-image'); + const svgContent = + ''; + const zipBuffer = makeZip({ 'diagram.svg': svgContent }); + const cleanupKeys = [`${prefix}archive.zip`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}archive.zip`, + Body: zipBuffer, + ContentType: 'application/zip' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'archive.zip').dblclick(); + await waitForObjectsLoaded(page); + + // Double-click the SVG file + await rowByName(page, 'diagram.svg').dblclick(); + + // Preview modal should show image + await expect(page.getByRole('heading', { name: 'diagram.svg' })).toBeVisible(); + await expect(page.getByAltText('Preview of diagram.svg')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); +}); diff --git a/e2e/storage/browsing.spec.ts b/e2e/storage/browsing.spec.ts index d0870d96..4694054b 100644 --- a/e2e/storage/browsing.spec.ts +++ b/e2e/storage/browsing.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { createS3Client, hasGarageCredentials, @@ -75,7 +75,7 @@ test.describe('Storage S3 — Browsing', () => { await expect(rowByName(page, 'empty-folder')).toBeVisible(); await rowByName(page, 'empty-folder').click(); - await expect(page.getByText('This bucket is empty')).toBeVisible(); + await expect(page.getByText('This folder is empty')).toBeVisible(); } finally { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } @@ -114,6 +114,59 @@ test.describe('Storage S3 — Browsing', () => { } }); + test('keeps page number and Prev/Next in sync with browser Back/Forward', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'paginate-history'); + // Default page size is 25, so 55 objects give three pages. + const cleanupKeys = Array.from( + { length: 55 }, + (_, index) => `${prefix}file-${String(index + 1).padStart(2, '0')}.txt` + ); + + const expectPage = async (pageNumber: number, visible: string, hidden: string[]) => { + await expect(page.getByText(`Page ${pageNumber}`, { exact: true })).toBeVisible(); + await expect(rowByName(page, visible)).toBeVisible(); + for (const name of hidden) await expect(rowByName(page, name)).not.toBeVisible(); + }; + const page1 = () => expectPage(1, 'file-01.txt', ['file-26.txt', 'file-51.txt']); + const page2 = () => expectPage(2, 'file-26.txt', ['file-01.txt', 'file-51.txt']); + const page3 = () => expectPage(3, 'file-51.txt', ['file-01.txt', 'file-26.txt']); + + try { + await Promise.all( + cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key)) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await page1(); + + await page.getByRole('button', { name: 'Next page' }).click(); + await page2(); + await page.getByRole('button', { name: 'Next page' }).click(); + await page3(); + + await page.goBack(); + await page2(); + await page.goBack(); + await page1(); + await page.goForward(); + await page2(); + + await page.getByRole('button', { name: 'Previous page' }).click(); + await page1(); + await page.getByRole('button', { name: 'Next page' }).click(); + await page2(); + await page.getByRole('button', { name: 'Next page' }).click(); + await page3(); + await expect(page.getByRole('button', { name: 'Next page' })).toBeDisabled(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + test('refresh button reloads the file listing', async ({ page }, testInfo) => { const credentials = requireGarageCredentials(); const client = createS3Client(credentials); diff --git a/e2e/storage/bucket-order.spec.ts b/e2e/storage/bucket-order.spec.ts new file mode 100644 index 00000000..71d49a9d --- /dev/null +++ b/e2e/storage/bucket-order.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createGarageBucketCredentials, + hasGarageAdmin, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { connectToStorage } from './helpers.js'; + +test.describe('Storage S3 — Bucket order', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials() || !hasGarageAdmin(), + 'Skipped: requires a running Garage instance with admin access' + ); + }); + + test('lists buckets alphabetically in the sidebar and the overview', async ({ + page + }, testInfo) => { + const base = requireGarageCredentials(); + const suffix = `${testInfo.project.name.toLowerCase()}-${crypto.randomUUID().slice(0, 8)}`; + const keyName = `bucket-order-${suffix}`; + // Created out of order; Garage does not list buckets in a fixed order. + const buckets = ['order-zulu', 'order-alpha', 'order-mike'].map((name) => `${name}-${suffix}`); + let credentials = base; + for (const bucketName of buckets) { + credentials = await createGarageBucketCredentials(base, { + bucketName, + keyName, + permissions: { owner: false, read: true, write: false } + }); + } + + await connectToStorage(page, credentials); + + const sorted = [...buckets].sort(); + const listed = async () => + (await page.getByRole('link').allTextContents()) + .map((text) => text.trim()) + .filter((text) => buckets.includes(text)); + // The sidebar comes first in the page, then the overview grid. + await expect.poll(listed).toEqual([...sorted, ...sorted]); + }); +}); diff --git a/e2e/storage/connection.spec.ts b/e2e/storage/connection.spec.ts index d74f1b7a..2ce6c8f7 100644 --- a/e2e/storage/connection.spec.ts +++ b/e2e/storage/connection.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { hasGarageCredentials, requireGarageCredentials } from '../support/garage.js'; import { connectToStorage, openConnectForm, bucketRoute } from './helpers.js'; @@ -40,12 +40,10 @@ test.describe('Storage S3 — Connection', () => { await page.getByLabel('Region').fill(credentials.region); await page.getByLabel('Access key').fill(credentials.accessKeyId); await page.getByLabel('Secret key').fill(`${credentials.secretAccessKey}-wrong`); - await page.getByRole('button', { name: 'Connect' }).click(); + await page.getByRole('button', { name: 'Connect', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Connect to storage' })).toBeVisible(); - await expect( - page.getByText('Could not connect — check the endpoint and credentials.') - ).toBeVisible(); + await expect(page.getByText('Access denied — check your credentials.')).toBeVisible(); }); test('disconnects from Garage S3', async ({ page }) => { @@ -68,7 +66,8 @@ test.describe('Storage S3 — Connection', () => { await connectToStorage(page, credentials); await expect(page).toHaveURL('/storage'); - await openConnectForm(page); + // Do not clear saved connections — we need the one we just saved. + await openConnectForm(page, { clearSaved: false }); const savedList = page.getByRole('list', { name: 'Saved connections' }); await expect(savedList).toBeVisible(); @@ -85,12 +84,16 @@ test.describe('Storage S3 — Connection', () => { await connectToStorage(page, credentials); await expect(page).toHaveURL('/storage'); - await openConnectForm(page); + // Do not clear saved connections — we need the one we just saved. + await openConnectForm(page, { clearSaved: false }); const savedList = page.getByRole('list', { name: 'Saved connections' }); await expect(savedList).toBeVisible(); - // Open the "More options" context menu for the first saved connection + // Count items before — retries accumulate connections in the DB, so there + // may be more than one. We only assert that forgetting ONE removes exactly + // one entry, not that the list becomes empty. + const countBefore = await savedList.getByRole('listitem').count(); await savedList.getByRole('listitem').first().getByRole('button').last().click(); // Click Delete in the context menu (rendered as a menuitem) @@ -99,7 +102,13 @@ test.describe('Storage S3 — Connection', () => { // Confirm deletion in the modal await page.getByRole('button', { name: 'Delete', exact: true }).click(); - await expect(page.getByText('No saved connections yet')).toBeVisible(); + if (countBefore === 1) { + // Last connection removed — list collapses entirely + await expect(page.getByText('No saved connections yet')).toBeVisible(); + } else { + // Other connections still exist — list shrinks by exactly one + await expect(savedList.getByRole('listitem')).toHaveCount(countBefore - 1); + } }); test('clicking a bucket tile in the grid navigates to the bucket explorer', async ({ page }) => { @@ -115,8 +124,10 @@ test.describe('Storage S3 — Connection', () => { await expect(bucketLink).toBeVisible(); await bucketLink.click(); - await expect(page).toHaveURL(bucketRoute(credentials.bucket)); - await expect(page.locator('nav[aria-label="breadcrumb"] [aria-current="page"]')).toContainText( + await expect(page).toHaveURL( + bucketRoute(new URL(credentials.endpoint).hostname, credentials.bucket) + ); + await expect(page.getByRole('navigation', { name: 'breadcrumb' })).toContainText( credentials.bucket ); }); diff --git a/e2e/storage/connections-management.spec.ts b/e2e/storage/connections-management.spec.ts index 04fae718..d39d5aa9 100644 --- a/e2e/storage/connections-management.spec.ts +++ b/e2e/storage/connections-management.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Page } from '../support/fixtures.js'; import { hasGarageCredentials, requireGarageCredentials } from '../support/garage.js'; import { connectToStorage, openConnectForm } from './helpers.js'; import { waitForHydration } from '../support/helpers.js'; @@ -34,7 +34,7 @@ test.describe('Storage — Connections management', () => { await expect(page.getByRole('link', { name: 'Manage connections' })).toBeVisible(); }); - test('navigates to /storage/connections from the manage link', async ({ page }) => { + test('navigates to /settings/connections from the manage link', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); await openConnectForm(page); @@ -42,14 +42,14 @@ test.describe('Storage — Connections management', () => { await page.getByRole('link', { name: 'Manage connections' }).click(); await waitForHydration(page); - await expect(page).toHaveURL('/storage/connections'); + await expect(page).toHaveURL('/settings/connections'); await expect(page.getByRole('heading', { name: 'Manage connections' })).toBeVisible(); }); test('lists saved connections on the management page', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); // Connections are displayed in a table; at least one data row should be visible. @@ -59,7 +59,7 @@ test.describe('Storage — Connections management', () => { test('Edit link on management page navigates to edit page', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); @@ -70,7 +70,7 @@ test.describe('Storage — Connections management', () => { test('edit page pre-fills with current connection values', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); @@ -78,10 +78,8 @@ test.describe('Storage — Connections management', () => { await expect(page.getByRole('textbox', { name: 'Host' })).toHaveValue( new URL(credentials.endpoint).hostname ); - await expect(page.getByRole('textbox', { name: 'Region' })).toHaveValue(credentials.region); - await expect(page.getByRole('textbox', { name: 'Access key' })).toHaveValue( - credentials.accessKeyId - ); + await expect(page.getByLabel('Region')).toHaveValue(credentials.region); + await expect(page.getByLabel('Access key')).toHaveValue(credentials.accessKeyId); }); test('edit page redirects to connections list after saving valid credentials', async ({ @@ -89,7 +87,7 @@ test.describe('Storage — Connections management', () => { }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); @@ -97,39 +95,58 @@ test.describe('Storage — Connections management', () => { await page.getByRole('button', { name: 'Save changes' }).click(); await waitForHydration(page); - await expect(page).toHaveURL('/storage/connections'); + await expect(page).toHaveURL('/settings/connections'); await expect(page.getByRole('heading', { name: 'Manage connections' })).toBeVisible(); }); - test('edit page shows error for invalid credentials', async ({ page }) => { + test('edit page keeps the current name when the name field is cleared', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); + const nameInput = page.getByLabel('Connection name'); + const originalName = await nameInput.inputValue(); + expect(originalName).not.toBe(''); - await page.getByLabel('Secret key').fill('wrong-secret'); + await nameInput.fill(' '); + await page.getByRole('button', { name: 'Save changes' }).click(); + await waitForHydration(page); + await expect(page).toHaveURL('/settings/connections'); + + await openFirstConnectionEditPage(page); + await expect(page.getByLabel('Connection name')).toHaveValue(originalName); + }); + + test('edit page requires the secret key when the access key changes', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await page.goto('/settings/connections'); + await waitForHydration(page); + + await openFirstConnectionEditPage(page); + await page.getByLabel('Access key').fill('GKnewaccesskey'); await page.getByRole('button', { name: 'Save changes' }).click(); await expect( - page.getByText('Could not connect — check the endpoint and credentials.') + page.getByText('Enter the secret key that belongs to the new access key.') ).toBeVisible(); + await expect(page).toHaveURL(/\/edit$/); }); - test('edit page requires the secret key when the access key changes', async ({ page }) => { + test('edit page shows error for invalid credentials', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); - await page.getByLabel('Access key').fill('new-access-key'); + await page.getByLabel('Secret key').fill('wrong-secret'); await page.getByRole('button', { name: 'Save changes' }).click(); - await expect(page.getByText('Enter the secret key for the new access key.')).toBeVisible(); - await expect(page).toHaveURL(/\/edit$/); + await expect(page.getByText('Access denied — check your credentials.')).toBeVisible(); }); test('delete button on management page removes the connection after confirmation', async ({ @@ -137,7 +154,7 @@ test.describe('Storage — Connections management', () => { }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); // Connections are displayed in a table; count data rows (excluding header). @@ -156,11 +173,11 @@ test.describe('Storage — Connections management', () => { await expect(dataRows).toHaveCount(initialItems - 1); }); - test('redirects to /storage/connections when editing a non-existent id', async ({ page }) => { - await page.goto('/storage/connections/00000000-0000-0000-0000-000000000000/edit'); + test('redirects to /settings/connections when editing a non-existent id', async ({ page }) => { + await page.goto('/settings/connections/00000000-0000-0000-0000-000000000000/edit'); await waitForHydration(page); - await expect(page).toHaveURL('/storage/connections'); + await expect(page).toHaveURL('/settings/connections'); }); test('edit page shows active connection notice when editing current connection', async ({ @@ -168,7 +185,7 @@ test.describe('Storage — Connections management', () => { }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); @@ -181,7 +198,7 @@ test.describe('Storage — Connections management', () => { }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); @@ -190,7 +207,7 @@ test.describe('Storage — Connections management', () => { await page.getByLabel('Connection name').fill('Changed name'); // Try to navigate away via the back link - await page.getByRole('link', { name: '← Manage connections', exact: true }).click(); + await page.getByRole('link', { name: '← Manage connections' }).click(); // Modal should appear await expect(page.getByRole('dialog')).toBeVisible(); @@ -200,13 +217,13 @@ test.describe('Storage — Connections management', () => { test('unsaved-changes modal: Stay keeps user on edit page', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); await page.getByLabel('Connection name').fill('Changed name'); - await page.getByRole('link', { name: '← Manage connections', exact: true }).click(); + await page.getByRole('link', { name: '← Manage connections' }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Stay on page' }).click(); @@ -217,17 +234,17 @@ test.describe('Storage — Connections management', () => { test('unsaved-changes modal: Leave navigates away', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); await page.getByLabel('Connection name').fill('Changed name'); - await page.getByRole('link', { name: '← Manage connections', exact: true }).click(); + await page.getByRole('link', { name: '← Manage connections' }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Leave' }).click(); await waitForHydration(page); - await expect(page).toHaveURL('/storage/connections'); + await expect(page).toHaveURL('/settings/connections'); }); }); diff --git a/e2e/storage/context-menu.spec.ts b/e2e/storage/context-menu.spec.ts index a9e961c0..9c92f3d9 100644 --- a/e2e/storage/context-menu.spec.ts +++ b/e2e/storage/context-menu.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { createS3Client, hasGarageCredentials, @@ -85,7 +85,7 @@ test.describe('Storage S3 — Context Menu', () => { await expect(page.getByRole('button', { name: 'Delete permanently' })).toBeVisible(); await page.getByRole('button', { name: 'Delete permanently' }).click(); - await expect(page.getByText('This bucket is empty')).toBeVisible(); + await expect(page.getByText('This folder is empty')).toBeVisible(); await expect(await objectExists(client, credentials.bucket, `${prefix}ctx-del.txt`)).toBe( false ); diff --git a/e2e/storage/create.spec.ts b/e2e/storage/create.spec.ts new file mode 100644 index 00000000..8c611db7 --- /dev/null +++ b/e2e/storage/create.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + connectAndOpenPrefix, + deleteKnownKeys, + objectExists, + putDirectoryMarker, + uniquePrefix +} from './helpers.js'; + +test.describe('Storage S3 — Create via context menu', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('creates a text file via empty-space right-click context menu', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'create-file'); + const newFile = `${prefix}hello.txt`; + const cleanupKeys = [newFile]; + + try { + await connectAndOpenPrefix(page, credentials, prefix); + + // Right-click on the empty-state row to trigger the empty-space context menu + await page.getByText('This folder is empty').click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + + await page.getByRole('menuitem', { name: 'New text file' }).click(); + + // The create modal appears with the name pre-filled + const input = page.locator('.modal-box input'); + await expect(input).toBeVisible(); + await input.fill('hello.txt'); + + await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click(); + + await expect.poll(() => objectExists(client, credentials.bucket, newFile)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('creates a folder via empty-space right-click context menu', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'create-folder'); + const newFolder = `${prefix}my-folder/`; + const cleanupKeys = [newFolder]; + + try { + await connectAndOpenPrefix(page, credentials, prefix); + + await page.getByText('This folder is empty').click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + + await page.getByRole('menuitem', { name: 'New directory' }).click(); + + const input = page.locator('.modal-box input'); + await expect(input).toBeVisible(); + await input.fill('my-folder'); + + await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click(); + + await expect.poll(() => objectExists(client, credentials.bucket, newFolder)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('the Create button popover takes a typed folder name without clicking the field', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'create-keyboard'); + const newFolder = `${prefix}typed/`; + + try { + await connectAndOpenPrefix(page, credentials, prefix); + + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByRole('menuitem', { name: 'New directory' }).click(); + const name = page.getByRole('textbox', { name: 'Name' }); + await expect(name).toBeFocused(); + await expect(name).toHaveValue('new-folder'); + + // The proposed name is selected, so typing replaces it. + await page.keyboard.type('typed'); + await page.keyboard.press('Enter'); + + await expect.poll(() => objectExists(client, credentials.bucket, newFolder)).toBe(true); + await expect(name).toBeHidden(); + + // Escape closes the popover while its field has focus. + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByRole('menuitem', { name: 'New text file' }).click(); + await expect(name).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(name).toBeHidden(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [newFolder]); + } + }); + + test('explains an invalid name at the input', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const prefix = uniquePrefix(testInfo, 'create-invalid'); + + await connectAndOpenPrefix(page, credentials, prefix); + + await page.getByText('This folder is empty').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'New text file' }).click(); + + const dialog = page.getByRole('dialog'); + const input = dialog.getByLabel('Name'); + await input.fill('x?y.txt'); + await input.press('Enter'); + + const message = dialog.locator('.modal-box p[aria-live="polite"]'); + await expect(message).toContainText('without the characters < > \\ | ? "'); + await expect(input).toHaveAttribute('aria-invalid', 'true'); + await expect(input).toHaveAttribute('aria-describedby', (await message.getAttribute('id'))!); + await expect(dialog.getByRole('button', { name: 'Create' })).toBeDisabled(); + + await input.fill('..'); + await expect(message).toContainText('".."'); + + await input.fill('notes//ok.txt'); + await expect(message).toContainText('Remove the extra "/"'); + await expect(input).toHaveAttribute('aria-invalid', 'true'); + + await input.fill('ok.txt'); + await expect(message).toBeEmpty(); + await expect(input).not.toHaveAttribute('aria-invalid'); + }); + + test('creates a folder inside a folder whose name has characters S3 allows', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = `${uniquePrefix(testInfo, 'create-odd-parent')}reports?2024/`; + const newFolder = `${prefix}q3/`; + const cleanupKeys = [prefix, newFolder]; + + try { + await putDirectoryMarker(client, credentials.bucket, prefix); + await connectAndOpenPrefix(page, credentials, prefix); + + await page.getByText('This folder is empty').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'New directory' }).click(); + + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Name').fill('q3'); + await dialog.getByRole('button', { name: 'Create' }).click(); + + await expect.poll(() => objectExists(client, credentials.bucket, newFolder)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); +}); diff --git a/e2e/storage/delete.spec.ts b/e2e/storage/delete.spec.ts index d647838f..36c0d074 100644 --- a/e2e/storage/delete.spec.ts +++ b/e2e/storage/delete.spec.ts @@ -1,4 +1,6 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; +import { readFile } from 'node:fs/promises'; +import { DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; import { createS3Client, hasGarageCredentials, @@ -38,13 +40,13 @@ test.describe('Storage S3 — Delete & Selection', () => { await page.getByRole('button', { name: 'Toggle selection mode' }).click(); await page.getByLabel('Select remove-me.txt').check(); - await page.getByLabel('Select archive').check(); + await page.getByLabel('Select archive', { exact: true }).check(); await page.getByRole('button', { name: 'Delete', exact: true }).click(); await expect(page.getByText('All contents will be permanently deleted')).toBeVisible(); - await page.getByRole('button', { name: 'Delete permanently' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click(); - await expect(page.getByText('This bucket is empty')).toBeVisible(); + await expect(page.getByText('This folder is empty')).toBeVisible(); await expect(await objectExists(client, credentials.bucket, `${prefix}remove-me.txt`)).toBe( false ); @@ -56,6 +58,119 @@ test.describe('Storage S3 — Delete & Selection', () => { } }); + test('deletes files whose keys use characters and segments S3 allows', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'delete-odd-keys'); + const names = ['what?.csv', 'a"bd|e\\f.txt', '..']; + const cleanupKeys = names.map((name) => `${prefix}${name}`); + + try { + for (const key of cleanupKeys) { + await putTextObject(client, credentials.bucket, key, 'odd'); + } + + await connectAndOpenPrefix(page, credentials, prefix); + + await page.getByRole('button', { name: 'Toggle selection mode' }).click(); + for (const name of names) { + await page.getByLabel(`Select ${name}`, { exact: true }).check(); + } + + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click(); + + await expect(page.getByText('This folder is empty')).toBeVisible(); + for (const key of cleanupKeys) { + expect(await objectExists(client, credentials.bucket, key)).toBe(false); + } + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('lists, previews, downloads and deletes keys with control characters, spaces, "+" and "%"', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'delete-encoded-keys'); + // Displayed names collapse whitespace, as Playwright's text matching does. + const files = [ + { name: 'tab\there.txt', shown: 'tab here.txt', body: 'tab body' }, + { name: 'new\nline.txt', shown: 'new line.txt', body: 'newline body' }, + { name: 'carriage\rreturn.txt', shown: 'carriage return.txt', body: 'return body' }, + // XML cannot carry U+0001, so it is deleted outside the DeleteObjects batch. + { name: 'start\u0001of-heading.txt', shown: 'start\u0001of-heading.txt', body: 'soh body' }, + { name: 'with space.txt', shown: 'with space.txt', body: 'space body' }, + { name: 'plus+sign.txt', shown: 'plus+sign.txt', body: 'plus body' }, + { name: '100%.txt', shown: '100%.txt', body: 'percent body' } + ]; + const cleanupKeys = files.map(({ name }) => `${prefix}${name}`); + + try { + for (const { name, body } of files) { + await putTextObject(client, credentials.bucket, `${prefix}${name}`, body); + } + + // Garage supports url-encoded listings, echoes the encoding type the app checks before + // decoding, and its encoding decodes back to the stored keys with the app's rule (`+` is a + // space, then percent-decoding). + const listing = await client.send( + new ListObjectsV2Command({ + Bucket: credentials.bucket, + Prefix: prefix, + EncodingType: 'url' + }) + ); + expect(listing.EncodingType).toBe('url'); + const decoded = (listing.Contents ?? []).map(({ Key }) => + decodeURIComponent((Key ?? '').replaceAll('+', ' ')) + ); + expect(decoded.sort()).toEqual([...cleanupKeys].sort()); + + await connectAndOpenPrefix(page, credentials, prefix); + for (const { shown } of files) { + await expect(rowByName(page, shown)).toBeVisible(); + } + + for (const { shown, body } of files) { + await rowByName(page, shown).dblclick(); + await expect(page.getByRole('heading', { name: shown })).toBeVisible(); + await expect(page.getByText(body, { exact: true })).toBeVisible(); + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); + await expect(page.getByRole('heading', { name: shown })).not.toBeVisible(); + } + + for (const { shown, body } of files) { + await rowByName(page, shown).click({ button: 'right' }); + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('menuitem', { name: 'Download' }).click() + ]); + expect(await download.failure()).toBeNull(); + expect(await readFile((await download.path())!, 'utf8')).toBe(body); + } + + await page.getByRole('button', { name: 'Toggle selection mode' }).click(); + await page.getByLabel('Select all').check(); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click(); + + await expect(page.getByText('This folder is empty')).toBeVisible(); + for (const key of cleanupKeys) { + expect(await objectExists(client, credentials.bucket, key)).toBe(false); + } + } finally { + // DeleteObjects cannot name the U+0001 key, so every key is deleted on its own. + for (const key of cleanupKeys) { + await client.send(new DeleteObjectCommand({ Bucket: credentials.bucket, Key: key })); + } + } + }); + test('cancel delete does not remove the selected file', async ({ page }, testInfo) => { const credentials = requireGarageCredentials(); const client = createS3Client(credentials); @@ -74,7 +189,7 @@ test.describe('Storage S3 — Delete & Selection', () => { await expect(page.getByText('This action cannot be undone.')).toBeVisible(); - await page.getByRole('button', { name: 'Cancel' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Cancel', exact: true }).click(); await expect(page.getByText('This action cannot be undone.')).not.toBeVisible(); await expect(rowByName(page, 'keep-me.txt')).toBeVisible(); @@ -119,4 +234,124 @@ test.describe('Storage S3 — Delete & Selection', () => { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } }); + + test('Delete keyboard shortcut opens the delete confirmation modal', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'kb-delete'); + const cleanupKeys = [`${prefix}delete-me.txt`]; + + try { + await putTextObject(client, credentials.bucket, `${prefix}delete-me.txt`, 'delete me'); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Select the file by clicking on the row + await rowByName(page, 'delete-me.txt').click(); + await expect(page.getByLabel('Select delete-me.txt')).toBeChecked(); + + // Press Delete key + await page.keyboard.press('Delete'); + + // Delete confirmation modal should appear + await expect(page.getByRole('heading', { name: /Delete.*delete-me.txt/i })).toBeVisible(); + await expect(page.getByText('This action cannot be undone.')).toBeVisible(); + + // Cancel the delete + await page.getByRole('dialog').getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByRole('heading', { name: /Delete.*delete-me.txt/i })).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('Delete outside the file listing does not open the delete modal', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'kb-delete-scope'); + const cleanupKeys = [`${prefix}keep-me.txt`]; + + try { + await putTextObject(client, credentials.bucket, `${prefix}keep-me.txt`, 'keep me'); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'keep-me.txt').click(); + await expect(page.getByLabel('Select keep-me.txt')).toBeChecked(); + + // Delete on the (only) tab in the tab bar must not reach the file shortcuts + await page.getByRole('tab').first().focus(); + await page.keyboard.press('Delete'); + + await expect(page.getByText('This action cannot be undone.')).not.toBeVisible(); + await expect(page.getByLabel('Select keep-me.txt')).toBeChecked(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('Enter on the focused Cancel button cancels the delete', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'kb-delete-cancel'); + const cleanupKeys = [`${prefix}keep-me.txt`]; + + try { + await putTextObject(client, credentials.bucket, `${prefix}keep-me.txt`, 'keep me'); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'keep-me.txt').click(); + await page.keyboard.press('Delete'); + await expect(page.getByText('This action cannot be undone.')).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'Cancel', exact: true }).focus(); + await page.keyboard.press('Enter'); + + await expect(page.getByText('This action cannot be undone.')).not.toBeVisible(); + await expect(rowByName(page, 'keep-me.txt')).toBeVisible(); + expect(await objectExists(client, credentials.bucket, `${prefix}keep-me.txt`)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('deleting a folder confirms it and closes the tabs that showed it', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'delete-open-folder'); + const cleanupKeys = [`${prefix}archive/2025/part-1.csv`]; + + try { + await putTextObject(client, credentials.bucket, cleanupKeys[0], 'part\n1\n', 'text/csv'); + await connectAndOpenPrefix(page, credentials, prefix); + + // A second tab shows the folder that is deleted from the first one. + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await page.getByRole('button', { name: 'New Tab' }).click(); + await rowByName(page, 'archive').click(); + await rowByName(page, '2025').click(); + await expect(rowByName(page, 'part-1.csv')).toBeVisible(); + await expect(tablist.getByRole('tab', { name: '2025' })).toBeVisible(); + await tablist.getByRole('tab').first().click(); + await expect(rowByName(page, 'archive')).toBeVisible(); + + await page.getByRole('button', { name: 'Actions for archive', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click(); + + await expect(page.getByRole('alert').filter({ hasText: 'Deleted "archive".' })).toBeVisible(); + await expect(rowByName(page, 'archive')).toHaveCount(0); + await expect(tablist.getByRole('tab')).toHaveCount(1); + await expect(tablist.getByRole('tab', { name: '2025' })).toHaveCount(0); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); }); diff --git a/e2e/storage/details.spec.ts b/e2e/storage/details.spec.ts new file mode 100644 index 00000000..8e78b8fb --- /dev/null +++ b/e2e/storage/details.spec.ts @@ -0,0 +1,202 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + connectAndOpenPrefix, + connectToStorage, + deleteKnownKeys, + modalBox, + putDirectoryMarker, + putTextObject, + rowByName, + uniquePrefix +} from './helpers.js'; + +test.describe('Storage S3 — Details Modal', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('opens file details modal from context menu', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'details-file'); + const cleanupKeys = [`${prefix}test.txt`]; + + try { + await putTextObject(client, credentials.bucket, `${prefix}test.txt`, 'file details test'); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'test.txt').click({ button: 'right' }); + + await page.getByRole('menuitem', { name: 'Details' }).click(); + await expect(page.getByRole('heading', { name: 'File Details' })).toBeVisible(); + + await expect(page.getByText('test.txt').first()).toBeVisible(); + await expect( + page.getByText('s3://' + credentials.bucket + '/' + prefix + 'test.txt') + ).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('opens directory details modal from context menu', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'details-dir'); + const cleanupKeys = [`${prefix}subdir/`, `${prefix}subdir/file.txt`]; + + try { + await putDirectoryMarker(client, credentials.bucket, `${prefix}subdir/`); + await putTextObject(client, credentials.bucket, `${prefix}subdir/file.txt`, 'nested'); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'subdir').click({ button: 'right' }); + + await page.getByRole('menuitem', { name: 'Details' }).click(); + await expect(page.getByRole('heading', { name: 'Directory Details' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('calculates the size of a directory', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'details-size'); + const cleanupKeys = [`${prefix}subdir/big.txt`, `${prefix}subdir/nested/small.txt`]; + + try { + await putTextObject(client, credentials.bucket, cleanupKeys[0], 'x'.repeat(2048)); + await putTextObject(client, credentials.bucket, cleanupKeys[1], 'tiny'); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'subdir').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Details' }).click(); + + const modal = modalBox(page); + await modal.getByRole('button', { name: 'Calculate directory size' }).click(); + await expect(modal.getByText('Total size')).toBeVisible(); + await expect(modal.getByText('2 files')).toBeVisible(); + await expect(modal.getByText('Stopped after')).toHaveCount(0); + + await modal.getByRole('tab', { name: 'Contents' }).click(); + await expect(modal.getByRole('cell', { name: 'big.txt' })).toBeVisible(); + await expect(modal.getByRole('cell', { name: 'nested/' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('shows partial results when the key limit is reached', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'details-truncated'); + const cleanupKeys = [`${prefix}subdir/file.txt`]; + + try { + await putTextObject(client, credentials.bucket, cleanupKeys[0], 'nested'); + // The real limit is far above what a test can list, so answer with a truncated result. + const result = { + type: 'complete', + totalSize: 3000, + totalKeys: 3, + totalFiles: 3, + totalDirectories: 0, + tree: { + name: 'subdir', + size: 3000, + children: [ + { name: 'a.bin', size: 2000, fullKey: `${prefix}subdir/a.bin` }, + { name: '', size: 1000, moreCount: 2 } + ] + }, + childrenByDepth: { + 1: { + items: [{ name: 'a.bin', size: 2000, isDirectory: false }], + omittedCount: 2, + omittedSize: 1000 + } + }, + maxDepth: 1, + durationMs: 1, + truncated: true + }; + await page.route('**/api/storage/directory-size?*', (route) => + route.fulfill({ + contentType: 'text/event-stream', + body: `data: ${JSON.stringify(result)}\n\n` + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'subdir').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Details' }).click(); + + const modal = modalBox(page); + await modal.getByRole('button', { name: 'Calculate directory size' }).click(); + await expect(modal.getByRole('status')).toContainText('Stopped after 3 objects'); + await expect(modal.getByText('2 more', { exact: true })).toBeVisible(); + + await modal.getByRole('tab', { name: 'Contents' }).click(); + await expect(modal.getByText('2 smaller entries not shown')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('opens bucket details from sidebar context menu', async ({ page }) => { + const credentials = requireGarageCredentials(); + const bucket = credentials.bucket; + + try { + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + const sidebar = page.getByRole('navigation', { name: 'Buckets' }); + await sidebar.getByText(bucket).click({ button: 'right' }); + + await page.getByRole('menuitem', { name: 'Details' }).click(); + await expect(page.getByRole('heading', { name: 'Bucket Details' })).toBeVisible(); + } finally { + // no cleanup needed — using the shared test bucket + } + }); + + test('marks bucket details sections that could not be loaded', async ({ page }) => { + const credentials = requireGarageCredentials(); + const bucket = credentials.bucket; + + await page.route( + (url) => + url.pathname === '/api/storage/buckets' && url.searchParams.get('details') === 'true', + (route) => + route.fulfill({ + json: { name: bucket, versioning: null, lifecycleRules: [], tags: null, acl: null } + }) + ); + + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + const sidebar = page.getByRole('navigation', { name: 'Buckets' }); + await sidebar.getByText(bucket).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Details' }).click(); + + const modal = page.getByRole('dialog'); + await expect(modal.getByRole('heading', { name: 'Bucket Details' })).toBeVisible(); + // Versioning, owner and tags failed; lifecycle loaded and is genuinely empty. + await expect(modal.getByText('Could not load')).toHaveCount(3); + await expect(modal.getByText('Disabled')).toHaveCount(0); + await expect(modal.getByText('No lifecycle rules')).toBeVisible(); + }); +}); diff --git a/e2e/storage/download.spec.ts b/e2e/storage/download.spec.ts index 5c9f1037..cbdb971f 100644 --- a/e2e/storage/download.spec.ts +++ b/e2e/storage/download.spec.ts @@ -1,4 +1,6 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; +import { readFile } from 'node:fs/promises'; +import AdmZip from 'adm-zip'; import { createS3Client, hasGarageCredentials, @@ -47,6 +49,8 @@ test.describe('Storage S3 — Download', () => { ]); expect(download.suggestedFilename()).toBe('download-me.txt'); + expect(await download.failure()).toBeNull(); + expect(await readFile((await download.path())!, 'utf8')).toBe('download content'); } finally { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } @@ -80,4 +84,124 @@ test.describe('Storage S3 — Download', () => { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } }); + + test('downloads up to three selected files individually', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'dl-multiple'); + const cleanupKeys = ['one.txt', 'two.txt', 'three.txt'].map((name) => `${prefix}${name}`); + + try { + await Promise.all( + cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key)) + ); + await connectAndOpenPrefix(page, credentials, prefix); + await page.getByRole('button', { name: 'Toggle selection mode' }).click(); + for (const name of ['one.txt', 'two.txt', 'three.txt']) { + await page.getByLabel(`Select ${name}`).check(); + } + + const downloads: string[] = []; + page.on('download', (download) => downloads.push(download.suggestedFilename())); + await page.getByRole('button', { name: 'Download', exact: true }).click(); + await expect.poll(() => downloads.sort()).toEqual(['one.txt', 'three.txt', 'two.txt']); + expect(downloads.sort()).toEqual(['one.txt', 'three.txt', 'two.txt']); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('archives a selection larger than three files', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'dl-archive'); + const cleanupKeys = ['one.txt', 'two.txt', 'three.txt', 'four.txt'].map( + (name) => `${prefix}${name}` + ); + + try { + await Promise.all( + cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key)) + ); + await connectAndOpenPrefix(page, credentials, prefix); + await page.getByRole('button', { name: 'Toggle selection mode' }).click(); + for (const name of ['one.txt', 'two.txt', 'three.txt', 'four.txt']) { + await page.getByLabel(`Select ${name}`).check(); + } + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Download', exact: true }).click() + ]); + expect(download.suggestedFilename()).toMatch(/\.zip$/); + expect(await download.failure()).toBeNull(); + const zip = new AdmZip(await readFile((await download.path())!)); + // Entries are named relative to the current folder; the content is the full key. + const entryName = (key: string) => key.slice(prefix.length); + expect( + zip + .getEntries() + .map((entry) => entry.entryName) + .sort() + ).toEqual(cleanupKeys.map(entryName).sort()); + for (const key of cleanupKeys) expect(zip.readAsText(entryName(key))).toBe(key); + await expect + .poll(() => + page.evaluate(() => { + const operations = JSON.parse( + localStorage.getItem('storage_operations_history') ?? '[]' + ) as Array<{ type: string; status: string; sourceNames: string[] }>; + return operations + .filter( + (operation) => + operation.type === 'download' && operation.sourceNames.includes('four.txt') + ) + .at(-1)?.status; + }) + ) + .toBe('done'); + await page.getByRole('button', { name: 'Operations', exact: true }).click(); + const history = page.getByRole('region', { name: 'Download history' }); + await history.getByRole('button').filter({ hasText: credentials.bucket }).first().click(); + const [redownload] = await Promise.all([ + page.waitForEvent('download'), + history.getByRole('button', { name: 'Download all', exact: true }).first().click() + ]); + expect(await redownload.failure()).toBeNull(); + const recreatedZip = new AdmZip(await readFile((await redownload.path())!)); + for (const key of cleanupKeys) expect(recreatedZip.readAsText(entryName(key))).toBe(key); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('downloads a folder via its context menu when not in selection mode', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'dl-folder'); + const cleanupKeys = [`${prefix}folder1/one.txt`, `${prefix}folder1/two.txt`]; + + try { + await Promise.all( + cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key)) + ); + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'folder1').click({ button: 'right' }); + + const downloadBtn = page.getByRole('menuitem', { name: 'Download' }); + await expect(downloadBtn).not.toHaveAttribute('disabled'); + + const [download] = await Promise.all([page.waitForEvent('download'), downloadBtn.click()]); + expect(download.suggestedFilename()).toMatch(/\.zip$/); + expect(await download.failure()).toBeNull(); + const zip = new AdmZip(await readFile((await download.path())!)); + // Entries are named relative to the current folder; the content is the full key. + for (const key of cleanupKeys) expect(zip.readAsText(key.slice(prefix.length))).toBe(key); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); }); diff --git a/e2e/storage/file-operations.spec.ts b/e2e/storage/file-operations.spec.ts new file mode 100644 index 00000000..8b51e834 --- /dev/null +++ b/e2e/storage/file-operations.spec.ts @@ -0,0 +1,880 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + connectAndOpenPrefix, + deleteKnownKeys, + objectExists, + putDirectoryMarker, + putTextObject, + rowByName, + uniquePrefix, + getObjectText +} from './helpers.js'; + +test.describe('Storage S3 — File Operations', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + // ────────────────────────────────────────────────────────────────────────── + // Cut + Paste + // ────────────────────────────────────────────────────────────────────────── + + test('cut via context menu and paste moves file (original deleted)', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'cut-paste'); + const srcKey = `${prefix}source/`; + const srcFile = `${srcKey}cut-me.txt`; + const cleanupKeys = [srcFile, `${prefix}cut-me.txt`]; + + try { + await putDirectoryMarker(client, credentials.bucket, srcKey); + await putTextObject(client, credentials.bucket, srcFile, 'cut paste test'); + + await connectAndOpenPrefix(page, credentials, srcKey); + + // Right-click the file and choose Cut + await rowByName(page, 'cut-me.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Cut' }).click(); + + // Navigate to parent prefix in-app (preserves clipboard) + await page.locator('tbody tr').first().click(); + await expect(page).toHaveURL((url) => url.pathname.endsWith(`/${prefix.slice(0, -1)}`)); + + // Click on table to focus it, then Ctrl+V to paste + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + await expect(page.getByText('1 item pasted')).toBeVisible(); + + // File should exist at dest (moved) + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}cut-me.txt`)) + .toBe(true); + // Original should be deleted (cut = move) + await expect.poll(() => objectExists(client, credentials.bucket, srcFile)).toBe(false); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('pastes to a nested folder via context menu', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'cut-paste-nested'); + const srcFile = `${prefix}nested-src.txt`; + const destDir = `${prefix}target/`; + const cleanupKeys = [srcFile, destDir]; + + try { + await putTextObject(client, credentials.bucket, srcFile, 'nested paste'); + await putDirectoryMarker(client, credentials.bucket, destDir); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Copy file + await rowByName(page, 'nested-src.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + + // Navigate into dest folder + await rowByName(page, 'target').dblclick(); + await expect(page).toHaveURL((url) => url.pathname.endsWith('/target')); + + // Paste (use keyboard shortcut — right-click in an empty folder + // lands on the ".." row which has no context menu handler) + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + await expect + .poll(() => objectExists(client, credentials.bucket, `${destDir}nested-src.txt`)) + .toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('paste with deleted source shows error toast', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'paste-deleted'); + const srcFile = `${prefix}gone.txt`; + const cleanupKeys = [srcFile]; + + try { + await putTextObject(client, credentials.bucket, srcFile, 'will be deleted'); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Copy the file + await rowByName(page, 'gone.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + + // Delete the file via S3 directly (simulate race condition / out-of-band delete) + await deleteKnownKeys(client, credentials.bucket, [srcFile]); + + // Navigate to bucket root via ".." row (preserves clipboard) + await page.locator('tbody tr').first().click(); + await expect(page).toHaveURL((url) => url.pathname.endsWith(`/${credentials.bucket}`)); + + await page.locator('tbody').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Paste' }).click(); + + // Should show error toast about source not found + const errorMsg = page.getByText(/could not paste|source.*deleted/i); + await expect(errorMsg).toBeVisible(); + + // The toast also has a Dismiss button + const toast = page.getByRole('alert').filter({ hasText: /could not paste|source.*deleted/i }); + const dismissBtn = toast.getByRole('button', { name: 'Dismiss' }); + await expect(dismissBtn).toBeVisible(); + await dismissBtn.click(); + await expect(toast).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + // ────────────────────────────────────────────────────────────────────────── + // Copy + Paste (multiple destinations) + // ────────────────────────────────────────────────────────────────────────── + + test('copy and paste to multiple destinations', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'multi-paste'); + const srcFile = `${prefix}multi.txt`; + const dest1 = `${prefix}d1/`; + const dest2 = `${prefix}d2/`; + const cleanupKeys = [srcFile, dest1, dest2]; + + try { + await putTextObject(client, credentials.bucket, srcFile, 'multi paste'); + await putDirectoryMarker(client, credentials.bucket, dest1); + await putDirectoryMarker(client, credentials.bucket, dest2); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Copy file + await rowByName(page, 'multi.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + + // Navigate into first destination and paste + await rowByName(page, 'd1').dblclick(); + await expect(page).toHaveURL((url) => url.pathname.endsWith('/d1')); + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + await expect + .poll(() => objectExists(client, credentials.bucket, `${dest1}multi.txt`)) + .toBe(true); + + // Navigate back to prefix then into second destination + await page.locator('tbody tr').first().click(); + await expect(page).toHaveURL((url) => url.pathname.endsWith(`/${prefix.slice(0, -1)}`)); + await rowByName(page, 'd2').dblclick(); + await expect(page).toHaveURL((url) => url.pathname.endsWith('/d2')); + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + await expect + .poll(() => objectExists(client, credentials.bucket, `${dest2}multi.txt`)) + .toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('pasting a folder over an existing folder merges it, keeping destination-only files', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'paste-folder-merge'); + const srcRoot = `${prefix}src/docs/`; + const destRoot = `${prefix}dest/docs/`; + const cleanupKeys = [ + `${prefix}src/`, + `${srcRoot}new.txt`, + `${srcRoot}shared.txt`, + `${prefix}dest/`, + `${destRoot}old.txt`, + `${destRoot}new.txt`, + `${destRoot}shared.txt` + ]; + + try { + await putDirectoryMarker(client, credentials.bucket, `${prefix}src/`); + await putTextObject(client, credentials.bucket, `${srcRoot}new.txt`, 'new'); + await putTextObject(client, credentials.bucket, `${srcRoot}shared.txt`, 'from source'); + await putDirectoryMarker(client, credentials.bucket, `${prefix}dest/`); + await putTextObject(client, credentials.bucket, `${destRoot}old.txt`, 'old'); + await putTextObject(client, credentials.bucket, `${destRoot}shared.txt`, 'from destination'); + + await connectAndOpenPrefix(page, credentials, `${prefix}src/`); + await rowByName(page, 'docs').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + + await page.locator('tbody tr').first().click(); + await rowByName(page, 'dest').dblclick(); + await expect(rowByName(page, 'docs')).toBeVisible(); + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + const conflictDialog = page.getByRole('dialog'); + await expect(conflictDialog.getByText('Files already exist', { exact: true })).toBeVisible(); + await expect( + conflictDialog.getByText(/keeps files that exist only in the destination/) + ).toBeVisible(); + await conflictDialog.getByRole('button', { name: 'Merge all' }).click(); + await conflictDialog.getByRole('button', { name: 'Paste', exact: true }).click(); + + await expect(page.getByText('1 item pasted')).toBeVisible(); + await expect + .poll(() => objectExists(client, credentials.bucket, `${destRoot}new.txt`)) + .toBe(true); + expect(await getObjectText(client, credentials.bucket, `${destRoot}shared.txt`)).toBe( + 'from source' + ); + expect(await getObjectText(client, credentials.bucket, `${destRoot}old.txt`)).toBe('old'); + expect(await getObjectText(client, credentials.bucket, `${srcRoot}new.txt`)).toBe('new'); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('renaming a pasted folder in the conflict dialog renames the whole folder', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'paste-folder-rename'); + const srcFile = `${prefix}src/docs/new.txt`; + const destRoot = `${prefix}dest/docs/`; + const renamedFile = `${prefix}dest/renamed/new.txt`; + const cleanupKeys = [ + `${prefix}src/`, + srcFile, + `${prefix}dest/`, + `${destRoot}old.txt`, + renamedFile, + `${prefix}dest/docs (1)/new.txt` + ]; + + try { + await putDirectoryMarker(client, credentials.bucket, `${prefix}src/`); + await putTextObject(client, credentials.bucket, srcFile, 'new'); + await putDirectoryMarker(client, credentials.bucket, `${prefix}dest/`); + await putTextObject(client, credentials.bucket, `${destRoot}old.txt`, 'old'); + + await connectAndOpenPrefix(page, credentials, `${prefix}src/`); + await rowByName(page, 'docs').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + + await page.locator('tbody tr').first().click(); + await rowByName(page, 'dest').dblclick(); + await expect(rowByName(page, 'docs')).toBeVisible(); + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + const conflictDialog = page.getByRole('dialog'); + await expect(conflictDialog.getByText('Files already exist', { exact: true })).toBeVisible(); + const conflictItem = conflictDialog.getByRole('listitem'); + await conflictItem.getByRole('button', { name: 'Rename', exact: true }).click(); + await conflictItem.getByLabel('New file name').fill('renamed'); + await conflictItem.getByRole('button', { name: 'Confirm name' }).click(); + await conflictDialog.getByRole('button', { name: 'Paste', exact: true }).click(); + + await expect.poll(() => objectExists(client, credentials.bucket, renamedFile)).toBe(true); + expect(await getObjectText(client, credentials.bucket, renamedFile)).toBe('new'); + expect(await objectExists(client, credentials.bucket, `${prefix}dest/docs (1)/new.txt`)).toBe( + false + ); + expect(await getObjectText(client, credentials.bucket, `${destRoot}old.txt`)).toBe('old'); + await expect(page.getByText('1 item pasted')).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('checking a new name in the conflict dialog reports names that are taken', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'paste-rename-check'); + // `docs/` and `taken/` exist only through their objects (no folder marker). + const cleanupKeys = [ + `${prefix}src/`, + `${prefix}src/a.txt`, + `${prefix}src/docs/new.txt`, + `${prefix}dest/`, + `${prefix}dest/a.txt`, + `${prefix}dest/b.txt`, + `${prefix}dest/docs/old.txt`, + `${prefix}dest/taken/x.txt`, + `${prefix}dest/free.txt`, + `${prefix}dest/fresh/new.txt` + ]; + + try { + await putDirectoryMarker(client, credentials.bucket, `${prefix}src/`); + await putTextObject(client, credentials.bucket, `${prefix}src/a.txt`, 'new a'); + await putTextObject(client, credentials.bucket, `${prefix}src/docs/new.txt`, 'new'); + await putDirectoryMarker(client, credentials.bucket, `${prefix}dest/`); + await putTextObject(client, credentials.bucket, `${prefix}dest/a.txt`, 'old a'); + await putTextObject(client, credentials.bucket, `${prefix}dest/b.txt`, 'b'); + await putTextObject(client, credentials.bucket, `${prefix}dest/docs/old.txt`, 'old'); + await putTextObject(client, credentials.bucket, `${prefix}dest/taken/x.txt`, 'x'); + + await connectAndOpenPrefix(page, credentials, `${prefix}src/`); + await expect(rowByName(page, 'docs')).toBeVisible(); + await page.keyboard.press('Control+a'); + await page.keyboard.press('Control+c'); + + await page.locator('tbody tr').first().click(); + await rowByName(page, 'dest').dblclick(); + await expect(rowByName(page, 'taken')).toBeVisible(); + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + const conflictDialog = page.getByRole('dialog'); + await expect(conflictDialog.getByText('Files already exist', { exact: true })).toBeVisible(); + const pasteButton = conflictDialog.getByRole('button', { name: 'Paste', exact: true }); + + for (const [original, taken, free] of [ + ['a.txt', 'b.txt', 'free.txt'], + ['docs', 'taken', 'fresh'] + ]) { + const item = conflictDialog.getByRole('listitem').filter({ hasText: original }); + await item.getByRole('button', { name: 'Rename', exact: true }).click(); + await item.getByLabel('New file name').fill(taken); + await item.getByRole('button', { name: 'Confirm name' }).click(); + await expect( + item.getByText('This name already exists here. Please choose a different name.') + ).toBeVisible(); + await expect(pasteButton).toBeDisabled(); + + await item.getByLabel('New file name').fill(free); + await item.getByRole('button', { name: 'Confirm name' }).click(); + await expect( + item.getByText('This name already exists here. Please choose a different name.') + ).toBeHidden(); + } + await expect(pasteButton).toBeEnabled(); + await pasteButton.click(); + + await expect(page.getByText('2 items pasted')).toBeVisible(); + expect(await getObjectText(client, credentials.bucket, `${prefix}dest/free.txt`)).toBe( + 'new a' + ); + expect(await getObjectText(client, credentials.bucket, `${prefix}dest/fresh/new.txt`)).toBe( + 'new' + ); + expect(await getObjectText(client, credentials.bucket, `${prefix}dest/b.txt`)).toBe('b'); + expect(await objectExists(client, credentials.bucket, `${prefix}dest/taken/new.txt`)).toBe( + false + ); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('copying items into their own folder creates renamed copies', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'paste-same-folder'); + const file = `${prefix}keep.txt`; + const folderFile = `${prefix}docs/inner.txt`; + const cleanupKeys = [file, folderFile, `${prefix}keep (1).txt`, `${prefix}docs (1)/inner.txt`]; + + try { + await putTextObject(client, credentials.bucket, file, 'keep'); + await putTextObject(client, credentials.bucket, folderFile, 'inner'); + + await connectAndOpenPrefix(page, credentials, prefix); + await page.getByRole('button', { name: 'Toggle selection mode' }).click(); + await page.getByLabel('Select keep.txt').check(); + await page.getByLabel('Select docs').check(); + await page.keyboard.press('Control+c'); + await page.keyboard.press('Control+v'); + + // Pasting onto themselves is not a conflict: the server copies to unique names. + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}keep (1).txt`)) + .toBe(true); + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}docs (1)/inner.txt`)) + .toBe(true); + await expect(page.getByText('Files already exist', { exact: true })).toHaveCount(0); + expect(await getObjectText(client, credentials.bucket, file)).toBe('keep'); + expect(await getObjectText(client, credentials.bucket, folderFile)).toBe('inner'); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('renamed copies keep dotted folder names intact', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = `${uniquePrefix(testInfo, 'paste-dotted')}dir.v2/`; + const file = `${prefix}file`; + const folderFile = `${prefix}v1.2/sub/inner.txt`; + const copiedFile = `${prefix}file (1)`; + const copiedFolderFile = `${prefix}v1.2 (1)/sub/inner.txt`; + const cleanupKeys = [file, folderFile, copiedFile, copiedFolderFile]; + + try { + await putTextObject(client, credentials.bucket, file, 'file'); + await putTextObject(client, credentials.bucket, folderFile, 'inner'); + + await connectAndOpenPrefix(page, credentials, prefix); + await page.getByRole('button', { name: 'Toggle selection mode' }).click(); + await page.getByLabel('Select file', { exact: true }).check(); + await page.getByLabel('Select v1.2', { exact: true }).check(); + await page.keyboard.press('Control+c'); + await page.keyboard.press('Control+v'); + + // Only the copied item's own name is numbered, never a parent folder. + await expect.poll(() => objectExists(client, credentials.bucket, copiedFile)).toBe(true); + await expect + .poll(() => objectExists(client, credentials.bucket, copiedFolderFile)) + .toBe(true); + expect(await getObjectText(client, credentials.bucket, copiedFolderFile)).toBe('inner'); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + // ────────────────────────────────────────────────────────────────────────── + // Rename + // ────────────────────────────────────────────────────────────────────────── + + test('renames a file via context menu', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'rename'); + const oldKey = `${prefix}old-name.txt`; + const newKey = `${prefix}new-name.txt`; + const cleanupKeys = [oldKey, newKey]; + + try { + await putTextObject(client, credentials.bucket, oldKey, 'rename test'); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'old-name.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Rename' }).click(); + + // Modal should be open with the name pre-filled and selected + const input = page.locator('.modal-box input'); + await expect(input).toBeVisible(); + + // Clear and type new name + await input.fill('new-name.txt'); + await page.getByRole('button', { name: 'Rename' }).click(); + + // Old name should be gone, new name should exist + await expect.poll(() => objectExists(client, credentials.bucket, oldKey)).toBe(false); + expect(await objectExists(client, credentials.bucket, newKey)).toBe(true); + + // Content should be preserved + const content = await getObjectText(client, credentials.bucket, newKey); + expect(content).toBe('rename test'); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('renames a file whose key uses characters S3 allows', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'rename-odd-key'); + const oldKey = `${prefix}what?.csv`; + const newKey = `${prefix}renamed.csv`; + const cleanupKeys = [oldKey, newKey]; + + try { + await putTextObject(client, credentials.bucket, oldKey, 'odd rename'); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'what?.csv').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Rename' }).click(); + await page.locator('.modal-box input').fill('renamed.csv'); + await page.getByRole('button', { name: 'Rename' }).click(); + + await expect.poll(() => objectExists(client, credentials.bucket, newKey)).toBe(true); + await expect.poll(() => objectExists(client, credentials.bucket, oldKey)).toBe(false); + expect(await getObjectText(client, credentials.bucket, newKey)).toBe('odd rename'); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('explains an invalid new name instead of closing the rename dialog', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'rename-invalid'); + const oldKey = `${prefix}keep.txt`; + const cleanupKeys = [oldKey, `${prefix}a/b.txt`]; + + try { + await putTextObject(client, credentials.bucket, oldKey, 'keep'); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'keep.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Rename' }).click(); + const input = page.getByLabel('New name'); + await input.fill('a/b.txt'); + await input.press('Enter'); + + const message = page.getByRole('dialog').locator('.modal-box p[aria-live="polite"]'); + await expect(message).toContainText('Enter a single name without "/"'); + await expect(input).toHaveAttribute('aria-invalid', 'true'); + await expect(input).toHaveAttribute('aria-describedby', (await message.getAttribute('id'))!); + await expect(page.getByRole('button', { name: 'Rename' })).toBeDisabled(); + await expect(input).toBeVisible(); + expect(await objectExists(client, credentials.bucket, oldKey)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('renaming onto a taken name shows an error and changes nothing', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'rename-conflict'); + const fileA = `${prefix}a.txt`; + const fileB = `${prefix}b.txt`; + const cleanupKeys = [fileA, fileB]; + + try { + await putTextObject(client, credentials.bucket, fileA, 'file a'); + await putTextObject(client, credentials.bucket, fileB, 'file b'); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'a.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Rename' }).click(); + + const input = page.locator('.modal-box input'); + await input.fill('b.txt'); + await page.getByRole('button', { name: 'Rename' }).click(); + + // The rename is refused: the modal stays open with the error. + await expect(page.locator('.modal-box').getByText(/already exists/)).toBeVisible(); + await expect(input).toBeVisible(); + expect(await getObjectText(client, credentials.bucket, fileA)).toBe('file a'); + expect(await getObjectText(client, credentials.bucket, fileB)).toBe('file b'); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + // ────────────────────────────────────────────────────────────────────────── + // Keyboard shortcuts + // ────────────────────────────────────────────────────────────────────────── + + test('Ctrl+C copies and Ctrl+V pastes a file', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'kb-copy-paste'); + const srcKey = `${prefix}src/`; + const srcFile = `${srcKey}kb-test.txt`; + const cleanupKeys = [srcFile]; + + try { + await putDirectoryMarker(client, credentials.bucket, srcKey); + await putTextObject(client, credentials.bucket, srcFile, 'kb test'); + + await connectAndOpenPrefix(page, credentials, srcKey); + + // Select the file + await rowByName(page, 'kb-test.txt').click(); + + // Press Ctrl+C to copy + await page.keyboard.press('Control+c'); + await expect(page.getByText('1 item copied')).toBeVisible(); + + // Navigate to parent prefix in-app (preserves clipboard) + await page.locator('tbody tr').first().click(); + await expect(page).toHaveURL((url) => url.pathname.endsWith(`/${prefix.slice(0, -1)}`)); + + // Select somewhere to focus the page, then Ctrl+V + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}kb-test.txt`)) + .toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('F2 renames a selected file', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'kb-rename'); + const oldKey = `${prefix}f2-old.txt`; + const newKey = `${prefix}f2-new.txt`; + const cleanupKeys = [oldKey, newKey]; + + try { + await putTextObject(client, credentials.bucket, oldKey, 'f2 rename'); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Select the file + await rowByName(page, 'f2-old.txt').click(); + + // Press F2 to open rename modal + await page.keyboard.press('F2'); + await expect(page.locator('.modal-box')).toBeVisible(); + + // Fill new name + const input = page.locator('.modal-box input'); + await input.fill('f2-new.txt'); + await page.keyboard.press('Enter'); + + await expect.poll(() => objectExists(client, credentials.bucket, oldKey)).toBe(false); + expect(await objectExists(client, credentials.bucket, newKey)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('Ctrl+X cuts and Ctrl+V pastes a file', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'kb-cut-paste'); + const srcKey = `${prefix}cutsrc/`; + const srcFile = `${srcKey}cut-kb.txt`; + const destFile = `${prefix}cut-kb.txt`; + const cleanupKeys = [srcFile, destFile]; + + try { + await putDirectoryMarker(client, credentials.bucket, srcKey); + await putTextObject(client, credentials.bucket, srcFile, 'cut kb test'); + + await connectAndOpenPrefix(page, credentials, srcKey); + + // Select the file + await rowByName(page, 'cut-kb.txt').click(); + + // Ctrl+X to cut + await page.keyboard.press('Control+x'); + await expect(page.getByText('1 item cut')).toBeVisible(); + + // Navigate to parent prefix in-app (preserves clipboard) + await page.locator('tbody tr').first().click(); + await expect(page).toHaveURL((url) => url.pathname.endsWith(`/${prefix.slice(0, -1)}`)); + + // Paste + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + // File should exist at destination + await expect.poll(() => objectExists(client, credentials.bucket, destFile)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + // ────────────────────────────────────────────────────────────────────────── + // Drag-and-drop (move) + // ────────────────────────────────────────────────────────────────────────── + + test('drags a file onto a folder to move it', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'drag-move'); + const destFolder = `${prefix}dest-folder/`; + const srcFile = `${prefix}drag-me.txt`; + const destFile = `${destFolder}drag-me.txt`; + const cleanupKeys = [srcFile, destFolder]; + + try { + await putTextObject(client, credentials.bucket, srcFile, 'drag move test'); + await putDirectoryMarker(client, credentials.bucket, destFolder); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Get source row and destination folder row positions + const sourceRow = rowByName(page, 'drag-me.txt'); + const destRow = rowByName(page, 'dest-folder'); + + await sourceRow.dragTo(destRow); + + // Confirm the move in the dialog + await page.getByRole('button', { name: 'Move' }).click(); + + // File should be moved to destination folder + await expect.poll(() => objectExists(client, credentials.bucket, srcFile)).toBe(false); + expect(await objectExists(client, credentials.bucket, destFile)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('renaming during a move conflict moves only the dragged file', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'move-conflict-rename'); + const destFolder = `${prefix}dest-folder/`; + const fileA = `${prefix}a.txt`; + const fileB = `${prefix}b.txt`; + const destA = `${destFolder}a.txt`; + const destB = `${destFolder}b.txt`; + const cleanupKeys = [fileA, fileB, destA, destB, destFolder]; + + try { + await putTextObject(client, credentials.bucket, fileA, 'file a'); + // A sibling of the dragged file already carries the chosen name. + await putTextObject(client, credentials.bucket, fileB, 'file b'); + await putTextObject(client, credentials.bucket, destA, 'existing a'); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'a.txt').dragTo(rowByName(page, 'dest-folder')); + await page.getByRole('button', { name: 'Move' }).click(); + + const conflictDialog = page.getByRole('dialog'); + await expect(conflictDialog.getByText('Files already exist', { exact: true })).toBeVisible(); + const conflictItem = conflictDialog.getByRole('listitem'); + await conflictItem.getByRole('button', { name: 'Rename', exact: true }).click(); + await conflictItem.getByLabel('New file name').fill('b.txt'); + await conflictItem.getByRole('button', { name: 'Confirm name' }).click(); + await conflictDialog.getByRole('button', { name: 'Move', exact: true }).click(); + + await expect.poll(() => objectExists(client, credentials.bucket, destB)).toBe(true); + expect(await getObjectText(client, credentials.bucket, destB)).toBe('file a'); + await expect.poll(() => objectExists(client, credentials.bucket, fileA)).toBe(false); + expect(await getObjectText(client, credentials.bucket, fileB)).toBe('file b'); + expect(await getObjectText(client, credentials.bucket, destA)).toBe('existing a'); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('drags a file onto parent directory row to move it up', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'drag-up'); + const subDir = `${prefix}sub/`; + const srcFile = `${subDir}up.txt`; + const cleanupKeys = [srcFile]; + + try { + await putDirectoryMarker(client, credentials.bucket, subDir); + await putTextObject(client, credentials.bucket, srcFile, 'move up'); + + await connectAndOpenPrefix(page, credentials, subDir); + + // Drag file onto parent directory ".." row + const sourceRow = rowByName(page, 'up.txt'); + const parentRow = page.locator('tbody tr').first(); // ".." parent row + + await sourceRow.dragTo(parentRow); + + // Confirm the move in the dialog + await page.getByRole('button', { name: 'Move' }).click(); + + // File should be at parent prefix + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}up.txt`)) + .toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('cancels drag-and-drop move via confirmation dialog', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'drag-cancel'); + const destFolder = `${prefix}dest/`; + const srcFile = `${prefix}stay-here.txt`; + const cleanupKeys = [srcFile, destFolder]; + + try { + await putTextObject(client, credentials.bucket, srcFile, 'should not move'); + await putDirectoryMarker(client, credentials.bucket, destFolder); + + await connectAndOpenPrefix(page, credentials, prefix); + + const sourceRow = rowByName(page, 'stay-here.txt'); + const destRow = rowByName(page, 'dest'); + await sourceRow.dragTo(destRow); + + // Cancel the move + await page.getByRole('button', { name: 'Cancel' }).click(); + + // File should still be at original location + expect(await objectExists(client, credentials.bucket, srcFile)).toBe(true); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('renames a folder subtree and removes its source marker', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'folder-rename'); + const oldRoot = `${prefix}docs/`; + const newRoot = `${prefix}renamed/`; + const cleanupKeys = [ + oldRoot, + `${oldRoot}one.txt`, + `${oldRoot}nested/two.txt`, + newRoot, + `${newRoot}one.txt`, + `${newRoot}nested/two.txt` + ]; + try { + await putDirectoryMarker(client, credentials.bucket, oldRoot); + await putTextObject(client, credentials.bucket, `${oldRoot}one.txt`, 'first'); + await putTextObject(client, credentials.bucket, `${oldRoot}nested/two.txt`, 'second'); + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'docs').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Rename' }).click(); + await page.locator('.modal-box input').fill('renamed'); + await page.getByRole('button', { name: 'Rename', exact: true }).click(); + await expect + .poll(() => objectExists(client, credentials.bucket, `${newRoot}nested/two.txt`)) + .toBe(true); + expect(await getObjectText(client, credentials.bucket, `${newRoot}one.txt`)).toBe('first'); + expect(await getObjectText(client, credentials.bucket, `${newRoot}nested/two.txt`)).toBe( + 'second' + ); + await expect.poll(() => objectExists(client, credentials.bucket, oldRoot)).toBe(false); + await expect + .poll(() => objectExists(client, credentials.bucket, `${oldRoot}one.txt`)) + .toBe(false); + expect(await objectExists(client, credentials.bucket, `${oldRoot}nested/two.txt`)).toBe( + false + ); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); +}); diff --git a/e2e/storage/helpers.ts b/e2e/storage/helpers.ts index 104e76b6..74113f06 100644 --- a/e2e/storage/helpers.ts +++ b/e2e/storage/helpers.ts @@ -25,9 +25,9 @@ export function uniqueBucketName(testInfo: TestInfo, scope: string): string { return `garage-${slug}-${testInfo.project.name.toLowerCase()}-${crypto.randomUUID().slice(0, 8)}`; } -export function bucketRoute(bucket: string, prefix = ''): string { +export function bucketRoute(connection: string, bucket: string, prefix = ''): string { if (!prefix) { - return `/storage/${encodeURIComponent(bucket)}`; + return `/storage/browse/${encodeURIComponent(connection)}/${encodeURIComponent(bucket)}`; } const trimmed = prefix.replace(/\/$/, ''); @@ -37,10 +37,41 @@ export function bucketRoute(bucket: string, prefix = ''): string { .map((segment) => encodeURIComponent(segment)) .join('/'); - return `/storage/${encodeURIComponent(bucket)}/${encoded}`; + return `/storage/browse/${encodeURIComponent(connection)}/${encodeURIComponent(bucket)}/${encoded}`; } -export async function openConnectForm(page: Page) { +export async function clearSavedConnections(page: Page) { + const savedList = page.getByRole('list', { name: 'Saved connections' }); + while (await savedList.isVisible().catch(() => false)) { + const items = savedList.getByRole('listitem'); + if ((await items.count()) === 0) break; + if ( + await items + .first() + .filter({ hasText: 'No saved connections yet' }) + .isVisible() + .catch(() => false) + ) + break; + // Click the last button in the first list item. On desktop this is the + // "more options" button (opacity-0, so use force). On mobile it's the + // X delete button which submits the form directly. + await items.first().getByRole('button').last().click({ force: true }); + // Desktop: a context menu appears — click Delete to open the modal. + // Mobile: the form submitted directly (no context menu), skip to hydration. + const deleteMenuItem = page.getByRole('menuitem', { name: 'Delete', exact: true }); + if (await deleteMenuItem.isVisible().catch(() => false)) { + await deleteMenuItem.click(); + // Confirm in the modal + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + } + // The form action POSTs and the server redirects back to /storage; + // wait for the page to re-hydrate before checking the list again. + await waitForHydration(page); + } +} + +export async function openConnectForm(page: Page, { clearSaved = true } = {}) { await page.goto('/'); if (new URL(page.url()).pathname.startsWith('/auth/login')) { await waitForHydration(page); @@ -49,7 +80,7 @@ export async function openConnectForm(page: Page) { await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); } - await page.goto('/storage?disconnected=1'); + await page.goto('/storage'); await waitForHydration(page); const connectHeading = page.getByRole('heading', { name: 'Connect to storage' }); @@ -59,14 +90,17 @@ export async function openConnectForm(page: Page) { (await disconnectButton.isVisible().catch(() => false)) ) { await disconnectButton.click(); - // A confirmation modal was added — confirm the disconnection if the modal appears. - const confirmButton = page.locator('.modal-box').getByRole('button', { name: 'Disconnect' }); - if (await confirmButton.isVisible({ timeout: 2_000 }).catch(() => false)) { - await confirmButton.click(); - } + // Confirm the disconnect dialog + await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click(); } await expect(connectHeading).toBeVisible(); + + // Clear all saved connections so each test starts from a clean state and + // cannot be disrupted by connections left over from previous tests or retries. + if (clearSaved) { + await clearSavedConnections(page); + } } export async function connectToStorage(page: Page, credentials: GarageCredentials) { @@ -90,11 +124,12 @@ export async function connectToStorage(page: Page, credentials: GarageCredential await page.getByLabel('Region').fill(credentials.region); await page.getByLabel('Access key').fill(credentials.accessKeyId); await page.getByLabel('Secret key').fill(credentials.secretAccessKey); - await page.getByRole('button', { name: 'Connect' }).click(); - // Wait for the redirect to /storage so that saveConnectionLocally() has been called - // before the test navigates elsewhere. Without this, a fast page.goto() call can race - // with the in-flight form-submission fetch and the connection is never persisted. - await page.waitForURL((url) => url.pathname === '/storage', { timeout: 15_000 }); + await page.getByRole('button', { name: 'Connect', exact: true }).click(); + // Wait for the client-side connected state to be established before returning. + // This confirms the session's activeStorageConnectionId is set and the layout + // has fetched the bucket list — reducing the window for session race conditions + // when parallel workers share the same server-side session. + await waitForStorageConnected(page); } export async function connectAndOpenPrefix( @@ -103,8 +138,10 @@ export async function connectAndOpenPrefix( prefix = '' ) { await connectToStorage(page, credentials); - await expect(page).toHaveURL('/storage'); - await page.goto(bucketRoute(credentials.bucket, prefix)); + const connection = new URL(credentials.endpoint).hostname; + const route = bucketRoute(connection, credentials.bucket, prefix); + + await page.goto(route); await waitForObjectsLoaded(page); } @@ -113,15 +150,19 @@ export async function connectAndOpenPrefix( * With the new architecture, `waitForHydration` alone is insufficient because * the object list is fetched client-side after hydration. This waits for either * a table row or the empty-state message to appear, confirming the fetch has - * completed and the UI has updated. + * completed and the UI has updated. The locator is scoped to the bucket object + * list because the storage overview also contains a table for recent items. */ -export async function waitForObjectsLoaded(page: Page) { +export async function waitForObjectsLoaded(page: Page, timeout = 15_000) { await waitForHydration(page); - await page + const objectList = page.getByTestId('storage-object-list'); + await objectList.waitFor({ state: 'visible', timeout }); + await page.getByTestId('storage-object-list-loading').waitFor({ state: 'hidden', timeout }); + await objectList .locator('tbody tr') - .or(page.getByText('This bucket is empty')) + .or(objectList.getByText(/^This (bucket|folder) is empty$/)) .first() - .waitFor({ timeout: 15_000 }); + .waitFor({ timeout }); } /** @@ -205,11 +246,14 @@ export async function getObjectText( } export function rowByName(page: Page, name: string) { - return page.locator('tbody tr', { hasText: name }).first(); + return page + .getByTestId('storage-object-list') + .locator('tbody tr') + .filter({ has: page.getByText(name, { exact: true }) }); } export function modalBox(page: Page) { - return page.locator('.modal-box').last(); + return page.getByRole('dialog').locator('.modal-box'); } export async function headObject(client: S3Client, bucket: string, key: string) { @@ -218,3 +262,19 @@ export async function headObject(client: S3Client, bucket: string, key: string) // Re-export expect for convenience export { expect }; + +/** Seeds the `storage_tabs` localStorage key before the first page load of a + * test, simulating a previous session's saved tab state. Because this uses + * `addInitScript` it runs on every navigation within the test context, so the + * data is available regardless of which page triggers the initial load. */ +export async function seedStorageTabsState( + page: Page, + state: { + tabs: Array<{ id: string; label: string; bucket: string; prefix: string; connection?: string }>; + activeTabId: string; + } +): Promise { + await page.addInitScript((data) => { + localStorage.setItem('storage_tabs', JSON.stringify(data)); + }, state); +} diff --git a/e2e/storage/links.spec.ts b/e2e/storage/links.spec.ts new file mode 100644 index 00000000..6c4ab0e4 --- /dev/null +++ b/e2e/storage/links.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + bucketRoute, + connectAndOpenPrefix, + deleteKnownKeys, + putTextObject, + rowByName, + uniquePrefix, + waitForObjectsLoaded +} from './helpers.js'; + +test.describe('Storage S3 — Links', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('a link with an encoded slash opens the folder it names', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'encoded-slash'); + const key = `${prefix}sales/2026/july.csv`; + + try { + await putTextObject(client, credentials.bucket, key, 'month\n7\n', 'text/csv'); + await connectAndOpenPrefix(page, credentials, prefix); + + const connection = new URL(credentials.endpoint).hostname; + const route = bucketRoute(connection, credentials.bucket, prefix); + await page.goto(`${route}/sales%2F2026`); + await waitForObjectsLoaded(page); + + await expect(rowByName(page, 'july.csv')).toBeVisible(); + const breadcrumb = page.getByRole('navigation', { name: 'Breadcrumb' }); + await expect(breadcrumb).toContainText('sales'); + await expect(breadcrumb).toContainText('2026'); + } finally { + await deleteKnownKeys(client, credentials.bucket, [key]); + } + }); + + test('a link to a folder that does not exist calls it an empty folder', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const prefix = uniquePrefix(testInfo, 'missing-folder'); + + await connectAndOpenPrefix(page, credentials, `${prefix}no/such/folder/`); + + await expect(page.getByText('This folder is empty')).toBeVisible(); + await expect(page.getByText('This bucket is empty')).toHaveCount(0); + }); + + test('a link to a storage that is not connected says so', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + const response = await page.goto(bucketRoute('other-storage.example', credentials.bucket)); + + expect(response?.status()).toBe(404); + await expect( + page.getByText('You are not connected to the storage “other-storage.example”.', { + exact: false + }) + ).toBeVisible(); + await expect(page.getByText('Internal Error')).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Back to storage' })).toBeVisible(); + }); +}); diff --git a/e2e/storage/permissions.spec.ts b/e2e/storage/permissions.spec.ts index b9b2f467..bd98cae1 100644 --- a/e2e/storage/permissions.spec.ts +++ b/e2e/storage/permissions.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { createGarageBucketCredentials, createS3Client, @@ -104,7 +104,7 @@ test.describe('Storage S3 — Permissions', () => { await connectToStorage(page, writeonlyCredentials); await expect(page.locator('main').getByRole('heading', { name: 'Buckets' })).toBeVisible(); - await page.goto(bucketRoute(writeonlyBucket)); + await page.goto(bucketRoute(new URL(baseCredentials.endpoint).hostname, writeonlyBucket)); await expect(page.getByText('403')).toBeVisible(); await expect(page.getByText('You do not have permission to access the bucket')).toBeVisible(); @@ -128,7 +128,7 @@ test.describe('Storage S3 — Permissions', () => { await connectToStorage(page, noAccessCredentials); await expect(page).toHaveURL('/storage'); - await page.goto(bucketRoute(noAccessBucket)); + await page.goto(bucketRoute(new URL(baseCredentials.endpoint).hostname, noAccessBucket)); await expect(page.getByText('403')).toBeVisible(); await expect(page.getByText('You do not have permission to access the bucket')).toBeVisible(); diff --git a/e2e/storage/preview.spec.ts b/e2e/storage/preview.spec.ts index 84bd1b52..53248485 100644 --- a/e2e/storage/preview.spec.ts +++ b/e2e/storage/preview.spec.ts @@ -1,5 +1,5 @@ import { PutObjectCommand } from '@aws-sdk/client-s3'; -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { createS3Client, hasGarageCredentials, @@ -8,11 +8,20 @@ import { import { connectAndOpenPrefix, deleteKnownKeys, + getObjectText, putTextObject, rowByName, uniquePrefix } from './helpers.js'; +// A minimal valid parquet file generated by pyarrow: +// id | name | score +// 1 | Alice | 95.5 +// 2 | Bob | 87.0 +// 3 | Charlie | 72.3 +const SAMPLE_PARQUET_BASE64 = + 'UEFSMRUEFSAVIEwVBBUAEgAAAQAAAAAAAAACAAAAAAAAABUAFRIVEiwVBBUQFQYVBhwYCAIAAAAAAAAAGAgBAAAAAAAAABYAKAgCAAAAAAAAABgIAQAAAAAAAAAREQAAAAIAAAAEAQEDAhUEFSAVIEwVBBUAEgAABQAAAEFsaWNlAwAAAEJvYhUAFRIVEiwVBBUQFQYVBhw2ACgDQm9iGAVBbGljZRERAAAAAgAAAAQBAQMCFQQVIBUgTBUEFQASAAAAAAAAAOBXQAAAAAAAwFVAFQAVEhUSLBUEFRAVBhUGHBgIAAAAAADgV0AYCAAAAAAAwFVAFgAoCAAAAAAA4FdAGAgAAAAAAMBVQBERAAAAAgAAAAQBAQMCFQQVEBUQTBUCFQASAAADAAAAAAAAABUAFRIVEiwVAhUQFQYVBhwYCAMAAAAAAAAAGAgDAAAAAAAAABYAKAgDAAAAAAAAABgIAwAAAAAAAAAREQAAAAIAAAACAQECABUEFRYVFkwVAhUAEgAABwAAAENoYXJsaWUVABUSFRIsFQIVEBUGFQYcNgAoB0NoYXJsaWUYB0NoYXJsaWUREQAAAAIAAAACAQECABUEFRAVEEwVAhUAEgAAMzMzMzMTUkAVABUSFRIsFQIVEBUGFQYcGAgzMzMzMxNSQBgIMzMzMzMTUkAWACgIMzMzMzMTUkAYCDMzMzMzE1JAEREAAAACAAAAAgEBAgAVBBlMNQAYBnNjaGVtYRUGABUEJQIYAmlkABUMJQIYBG5hbWUlAEwcAAAAFQolAhgFc2NvcmUAFgYZLBk8JgAcFQQZNQAGEBkYAmlkFQAWBBbMARbMASZEJggcGAgCAAAAAAAAABgIAQAAAAAAAAAWACgIAgAAAAAAAAAYCAEAAAAAAAAAEREAGSwVBBUAFQIAFQAVEBUCADwpBhkmAAQAAAAmABwVDBk1AAYQGRgEbmFtZRUAFgQWlAEWlAEmkAIm1AEcNgAoA0JvYhgFQWxpY2UREQAZLBUEFQAVAgAVABUQFQIAPBYQGQYZJgAEAAAAJgAcFQoZNQAGEBkYBXNjb3JlFQAWBBbMARbMASakAyboAhwYCAAAAAAA4FdAGAgAAAAAAMBVQBYAKAgAAAAAAOBXQBgIAAAAAADAVUAREQAZLBUEFQAVAgAVABUQFQIAPCkGGSYABAAAABasBBYEJggWrAQAGTwmABwVBBk1AAYQGRgCaWQVABYCFrwBFrwBJuAEJrQEHBgIAwAAAAAAAAAYCAMAAAAAAAAAFgAoCAMAAAAAAAAAGAgDAAAAAAAAABERABksFQQVABUCABUAFRAVAgA8KQYZJgACAAAAJgAcFQwZNQAGEBkYBG5hbWUVABYCFpYBFpYBJqIGJvAFHDYAKAdDaGFybGllGAdDaGFybGllEREAGSwVBBUAFQIAFQAVEBUCADwWDhkGGSYAAgAAACYAHBUKGTUABhAZGAVzY29yZRUAFgIWvAEWvAEmsgcmhgccGAgzMzMzMxNSQBgIMzMzMzMTUkAWACgIMzMzMzMTUkAYCDMzMzMzE1JAEREAGSwVBBUAFQIAFQAVEBUCADwpBhkmAAIAAAAWjgQWAia0BBaOBAAZHBgMQVJST1c6c2NoZW1hGLgCLy8vLy8rQUFBQUFRQUFBQUFBQUtBQXdBQmdBRkFBZ0FDZ0FBQUFBQkJBQU1BQUFBQ0FBSUFBQUFCQUFJQUFBQUJBQUFBQU1BQUFCNEFBQUFPQUFBQUFRQUFBQ2svLy8vQUFBQkF4QUFBQUFjQUFBQUJBQUFBQUFBQUFBRkFBQUFjMk52Y21VQUJnQUlBQVlBQmdBQUFBQUFBZ0RVLy8vL0FBQUJCUkFBQUFBY0FBQUFCQUFBQUFBQUFBQUVBQUFBYm1GdFpRQUFBQUFFQUFRQUJBQUFBQkFBRkFBSUFBWUFCd0FNQUFBQUVBQVFBQUFBQUFBQkFoQUFBQUFjQUFBQUJBQUFBQUFBQUFBQ0FBQUFhV1FBQUFnQURBQUlBQWNBQ0FBQUFBQUFBQUZBQUFBQUFBQUFBQT09ABggcGFycXVldC1jcHAtYXJyb3cgdmVyc2lvbiAyNC4wLjAZPBwAABwAABwAAAAUBAAAUEFSMQ=='; + test.describe('Storage S3 — Preview', () => { test.use({ locale: 'en-US' }); @@ -44,41 +53,13 @@ test.describe('Storage S3 — Preview', () => { await expect(page.getByText('Preview line one')).toBeVisible(); await expect(page.getByText('Preview line two')).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).last().click(); + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); await expect(page.getByRole('heading', { name: 'preview.txt' })).not.toBeVisible(); } finally { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } }); - test('shows fallback preview for a known binary file type', async ({ page }, testInfo) => { - const credentials = requireGarageCredentials(); - const client = createS3Client(credentials); - const prefix = uniquePrefix(testInfo, 'preview-zip'); - const cleanupKeys = [`${prefix}archive.zip`]; - - try { - await client.send( - new PutObjectCommand({ - Bucket: credentials.bucket, - Key: `${prefix}archive.zip`, - Body: Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]), - ContentType: 'application/zip' - }) - ); - - await connectAndOpenPrefix(page, credentials, prefix); - - await rowByName(page, 'archive.zip').dblclick(); - - await expect(page.getByRole('heading', { name: 'archive.zip' })).toBeVisible(); - await expect(page.getByText('Preview unavailable')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Download full file' })).toBeVisible(); - } finally { - await deleteKnownKeys(client, credentials.bucket, cleanupKeys); - } - }); - test('shows binary fallback preview for non-decodable binary content', async ({ page }, testInfo) => { @@ -277,6 +258,355 @@ test.describe('Storage S3 — Preview', () => { } }); + test('previews parquet files with column headers and data rows', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-parquet'); + const cleanupKeys = [`${prefix}data.parquet`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}data.parquet`, + Body: Buffer.from(SAMPLE_PARQUET_BASE64, 'base64'), + ContentType: 'application/vnd.apache.parquet' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'data.parquet').dblclick(); + + // Modal heading + await expect(page.getByRole('heading', { name: 'data.parquet' })).toBeVisible(); + + // Default view is Metadata tab — schema column names should be visible in the Schema table + const schemaTable = page.getByRole('table', { name: 'Schema' }); + await expect(schemaTable.getByRole('cell', { name: 'id' })).toBeVisible(); + await expect(schemaTable.getByRole('cell', { name: 'name' })).toBeVisible(); + await expect(schemaTable.getByRole('cell', { name: 'score' })).toBeVisible(); + + // Tabs are visible + await expect(page.getByRole('tab', { name: 'Metadata' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Data', exact: true })).toBeVisible(); + + // Click the Data tab to view the data table + await page.getByRole('tab', { name: 'Data', exact: true }).click(); + + // Parquet preview table with correct aria label + const table = page.getByRole('table', { name: 'Parquet preview' }); + await expect(table).toBeVisible(); + + // Column headers in data table + await expect(table.locator('th', { hasText: 'id' })).toBeVisible(); + await expect(table.locator('th', { hasText: 'name' })).toBeVisible(); + await expect(table.locator('th', { hasText: 'score' })).toBeVisible(); + + // Data rows + await expect(table.locator('td', { hasText: 'Alice' })).toBeVisible(); + await expect(table.locator('td', { hasText: 'Bob' })).toBeVisible(); + await expect(table.locator('td', { hasText: 'Charlie' })).toBeVisible(); + + // File size badge + await expect(page.locator('.badge', { hasText: /B$/ })).toBeVisible(); + + // Close button works + await page.getByRole('button', { name: 'Close' }).last().click(); + await expect(page.getByRole('heading', { name: 'data.parquet' })).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('rejects parquet preview requests with unbounded row ranges', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-parquet-bounds'); + const cleanupKeys = [`${prefix}data.parquet`]; + + try { + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}data.parquet`, + Body: Buffer.from(SAMPLE_PARQUET_BASE64, 'base64'), + ContentType: 'application/vnd.apache.parquet' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'data.parquet').dblclick(); + await expect(page.getByRole('heading', { name: 'data.parquet' })).toBeVisible(); + + // Capture the client's own data chunk request so the replay carries the + // same connection headers, then tamper with the row range. + const dataRequest = page.waitForRequest( + (req) => req.url().includes('/api/storage/preview') && req.url().includes('data=true') + ); + await page.getByRole('tab', { name: 'Data', exact: true }).click(); + const captured = await dataRequest; + const headers = await captured.allHeaders(); + const url = new URL(captured.url()); + expect(Number(url.searchParams.get('limit'))).toBeLessThanOrEqual(250); + + const ok = await page.request.get(url.toString(), { headers }); + expect(ok.status()).toBe(200); + + for (const [param, value] of [ + ['limit', '1000000000'], + ['limit', '1e9'], + ['offset', '-1'] + ]) { + const tampered = new URL(url); + tampered.searchParams.set(param, value); + const res = await page.request.get(tampered.toString(), { headers }); + expect(res.status(), `${param}=${value}`).toBe(400); + } + + await expect( + page.getByRole('table', { name: 'Parquet preview' }).locator('td', { hasText: 'Alice' }) + ).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('previews parquet file with application/x-parquet content type', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-parquet-x'); + const cleanupKeys = [`${prefix}measurements.parquet`]; + + try { + const parquetBase64 = + 'UEFSMRUEFSAVIEwVBBUAEgAAAQAAAAAAAAACAAAAAAAAABUAFRIVEiwVBBUQFQYVBhwYCAIAAAAAAAAAGAgBAAAAAAAAABYAKAgCAAAAAAAAABgIAQAAAAAAAAAREQAAAAIAAAAEAQEDAhUEFSAVIEwVBBUAEgAABQAAAEFsaWNlAwAAAEJvYhUAFRIVEiwVBBUQFQYVBhw2ACgDQm9iGAVBbGljZRERAAAAAgAAAAQBAQMCFQQVIBUgTBUEFQASAAAAAAAAAOBXQAAAAAAAwFVAFQAVEhUSLBUEFRAVBhUGHBgIAAAAAADgV0AYCAAAAAAAwFVAFgAoCAAAAAAA4FdAGAgAAAAAAMBVQBERAAAAAgAAAAQBAQMCFQQVEBUQTBUCFQASAAADAAAAAAAAABUAFRIVEiwVAhUQFQYVBhwYCAMAAAAAAAAAGAgDAAAAAAAAABYAKAgDAAAAAAAAABgIAwAAAAAAAAAREQAAAAIAAAACAQECABUEFRYVFkwVAhUAEgAABwAAAENoYXJsaWUVABUSFRIsFQIVEBUGFQYcNgAoB0NoYXJsaWUYB0NoYXJsaWUREQAAAAIAAAACAQECABUEFRAVEEwVAhUAEgAAMzMzMzMTUkAVABUSFRIsFQIVEBUGFQYcGAgzMzMzMxNSQBgIMzMzMzMTUkAWACgIMzMzMzMTUkAYCDMzMzMzE1JAEREAAAACAAAAAgEBAgAVBBlMNQAYBnNjaGVtYRUGABUEJQIYAmlkABUMJQIYBG5hbWUlAEwcAAAAFQolAhgFc2NvcmUAFgYZLBk8JgAcFQQZNQAGEBkYAmlkFQAWBBbMARbMASZEJggcGAgCAAAAAAAAABgIAQAAAAAAAAAWACgIAgAAAAAAAAAYCAEAAAAAAAAAEREAGSwVBBUAFQIAFQAVEBUCADwpBhkmAAQAAAAmABwVDBk1AAYQGRgEbmFtZRUAFgQWlAEWlAEmkAIm1AEcNgAoA0JvYhgFQWxpY2UREQAZLBUEFQAVAgAVABUQFQIAPBYQGQYZJgAEAAAAJgAcFQoZNQAGEBkYBXNjb3JlFQAWBBbMARbMASakAyboAhwYCAAAAAAA4FdAGAgAAAAAAMBVQBYAKAgAAAAAAOBXQBgIAAAAAADAVUAREQAZLBUEFQAVAgAVABUQFQIAPCkGGSYABAAAABasBBYEJggWrAQAGTwmABwVBBk1AAYQGRgCaWQVABYCFrwBFrwBJuAEJrQEHBgIAwAAAAAAAAAYCAMAAAAAAAAAFgAoCAMAAAAAAAAAGAgDAAAAAAAAABERABksFQQVABUCABUAFRAVAgA8KQYZJgACAAAAJgAcFQwZNQAGEBkYBG5hbWUVABYCFpYBFpYBJqIGJvAFHDYAKAdDaGFybGllGAdDaGFybGllEREAGSwVBBUAFQIAFQAVEBUCADwWDhkGGSYAAgAAACYAHBUKGTUABhAZGAVzY29yZRUAFgIWvAEWvAEmsgcmhgccGAgzMzMzMxNSQBgIMzMzMzMTUkAWACgIMzMzMzMTUkAYCDMzMzMzE1JAEREAGSwVBBUAFQIAFQAVEBUCADwpBhkmAAIAAAAWjgQWAia0BBaOBAAZHBgMQVJST1c6c2NoZW1hGLgCLy8vLy8rQUFBQUFRQUFBQUFBQUtBQXdBQmdBRkFBZ0FDZ0FBQUFBQkJBQU1BQUFBQ0FBSUFBQUFCQUFJQUFBQUJBQUFBQU1BQUFCNEFBQUFPQUFBQUFRQUFBQ2svLy8vQUFBQkF4QUFBQUFjQUFBQUJBQUFBQUFBQUFBRkFBQUFjMk52Y21VQUJnQUlBQVlBQmdBQUFBQUFBZ0RVLy8vL0FBQUJCUkFBQUFBY0FBQUFCQUFBQUFBQUFBQUVBQUFBYm1GdFpRQUFBQUFFQUFRQUJBQUFBQkFBRkFBSUFBWUFCd0FNQUFBQUVBQVFBQUFBQUFBQkFoQUFBQUFjQUFBQUJBQUFBQUFBQUFBQ0FBQUFhV1FBQUFnQURBQUlBQWNBQ0FBQUFBQUFBQUZBQUFBQUFBQUFBQT09ABggcGFycXVldC1jcHAtYXJyb3cgdmVyc2lvbiAyNC4wLjAZPBwAABwAABwAAAAUBAAAUEFSMQ=='; + + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}measurements.parquet`, + Body: Buffer.from(parquetBase64, 'base64'), + ContentType: 'application/x-parquet' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'measurements.parquet').dblclick(); + + await expect(page.getByRole('heading', { name: 'measurements.parquet' })).toBeVisible(); + + // Schema column names should be visible in default metadata tab + await expect( + page.getByRole('table', { name: 'Schema' }).getByRole('cell', { name: 'name' }) + ).toBeVisible(); + + // Click Data tab to view data table + await page.getByRole('tab', { name: 'Data', exact: true }).click(); + const table = page.getByRole('table', { name: 'Parquet preview' }); + await expect(table).toBeVisible(); + await expect(table.locator('th', { hasText: 'name' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('previews TSV files with table headers and data rows', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-tsv'); + const cleanupKeys = [`${prefix}data.tsv`]; + + try { + const tsvContent = ['name\tcity\tscore', 'Alice\tBerlin\t95', 'Bob\tMunich\t88'].join('\n'); + + await putTextObject( + client, + credentials.bucket, + `${prefix}data.tsv`, + tsvContent, + 'text/tab-separated-values' + ); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'data.tsv').dblclick(); + + // Modal heading + await expect(page.getByRole('heading', { name: 'data.tsv' })).toBeVisible(); + + // CSV/TSV table is rendered with correct aria label + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect(table).toBeVisible(); + + // Headers are present + await expect(table.locator('th', { hasText: 'name' })).toBeVisible(); + await expect(table.locator('th', { hasText: 'city' })).toBeVisible(); + await expect(table.locator('th', { hasText: 'score' })).toBeVisible(); + + // Data rows are present + await expect(table.locator('td', { hasText: 'Alice' })).toBeVisible(); + await expect(table.locator('td', { hasText: 'Berlin' })).toBeVisible(); + await expect(table.locator('td', { hasText: '95' })).toBeVisible(); + await expect(table.locator('td', { hasText: 'Bob' })).toBeVisible(); + + // File size badge is displayed + await expect(page.locator('.badge', { hasText: /B$/ })).toBeVisible(); + + // Close button works + await page.getByRole('button', { name: 'Close' }).last().click(); + await expect(page.getByRole('heading', { name: 'data.tsv' })).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('previews TSV files with quoted fields containing tabs', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-tsv-quoted'); + const cleanupKeys = [`${prefix}notes.tsv`]; + + try { + const tsvContent = ['name\tnotes', 'Alice\t"likes\ttabs\tin\tdata"', 'Bob\tplain text'].join( + '\n' + ); + + await putTextObject( + client, + credentials.bucket, + `${prefix}notes.tsv`, + tsvContent, + 'text/tab-separated-values' + ); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'notes.tsv').dblclick(); + + await expect(page.getByRole('heading', { name: 'notes.tsv' })).toBeVisible(); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect(table).toBeVisible(); + + // Quoted fields with tabs are parsed correctly + await expect(table.locator('td', { hasText: 'likes\ttabs\tin\tdata' })).toBeVisible(); + await expect(table.locator('td', { hasText: 'plain text' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('previews TSV files with application/vnd.ms-excel content type', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-tsv-excel'); + const cleanupKeys = [`${prefix}export.tsv`]; + + try { + const tsvContent = ['product\tprice\tqty', 'Widget\t19.99\t100', 'Gadget\t49.95\t50'].join( + '\n' + ); + + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}export.tsv`, + Body: Buffer.from(tsvContent), + ContentType: 'application/vnd.ms-excel' + }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'export.tsv').dblclick(); + + await expect(page.getByRole('heading', { name: 'export.tsv' })).toBeVisible(); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect(table).toBeVisible(); + + await expect(table.locator('th', { hasText: 'product' })).toBeVisible(); + await expect(table.locator('th', { hasText: 'price' })).toBeVisible(); + await expect(table.locator('th', { hasText: 'qty' })).toBeVisible(); + + await expect(table.locator('td', { hasText: 'Widget' })).toBeVisible(); + await expect(table.locator('td', { hasText: '19.99' })).toBeVisible(); + await expect(table.locator('td', { hasText: '100' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('saves the complete content of a text file larger than the preview limit', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-save-large'); + const key = `${prefix}large.txt`; + + // ~583 KiB of two-byte characters: above the 256 KiB text preview limit + // and adapter-node's default 512K body limit, below the 5 MiB edit limit, + // with a character straddling byte 262144. + const line = `${'ä'.repeat(99)}\n`; + const original = line.repeat(3000); + + try { + await putTextObject(client, credentials.bucket, key, original); + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'large.txt').dblclick(); + const editor = page.getByRole('application', { name: 'Text editor' }); + await expect(editor).toHaveAttribute('data-ready', 'true'); + await expect(page.getByTestId('editor-readonly-notice')).not.toBeVisible(); + await expect(editor.locator(':focus')).toHaveCount(1); + + await page.keyboard.press('ControlOrMeta+Home'); + await page.keyboard.type('EDIT-'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved', { exact: true })).toBeVisible(); + + expect(await getObjectText(client, credentials.bucket, key)).toBe(`EDIT-${original}`); + } finally { + await deleteKnownKeys(client, credentials.bucket, [key]); + } + }); + + test('refuses to save over a file that changed after it was opened', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-save-conflict'); + const key = `${prefix}conflict.txt`; + + try { + await putTextObject(client, credentials.bucket, key, 'original content\n'); + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'conflict.txt').dblclick(); + const editor = page.getByRole('application', { name: 'Text editor' }); + await expect(editor).toHaveAttribute('data-ready', 'true'); + await expect(editor.locator(':focus')).toHaveCount(1); + await page.keyboard.press('ControlOrMeta+Home'); + await page.keyboard.type('mine '); + + await putTextObject(client, credentials.bucket, key, 'concurrent change\n'); + + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText(/changed or deleted after you opened it/)).toBeVisible(); + expect(await getObjectText(client, credentials.bucket, key)).toBe('concurrent change\n'); + } finally { + await deleteKnownKeys(client, credentials.bucket, [key]); + } + }); + test('closes preview modal when the Escape key is pressed', async ({ page }, testInfo) => { const credentials = requireGarageCredentials(); const client = createS3Client(credentials); @@ -295,6 +625,9 @@ test.describe('Storage S3 — Preview', () => { await rowByName(page, 'escape.txt').dblclick(); await expect(page.getByRole('heading', { name: 'escape.txt' })).toBeVisible(); + const editor = page.getByRole('application', { name: 'Text editor' }); + await expect(editor).toHaveAttribute('data-ready', 'true'); + await expect(editor.locator(':focus')).toHaveCount(1); await page.keyboard.press('Escape'); @@ -303,4 +636,75 @@ test.describe('Storage S3 — Preview', () => { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } }); + + test('Escape closes the editor find widget before the preview', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-escape-find'); + const cleanupKeys = [`${prefix}find.txt`]; + + try { + await putTextObject(client, credentials.bucket, `${prefix}find.txt`, 'find me\n'); + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'find.txt').dblclick(); + const heading = page.getByRole('heading', { name: 'find.txt' }); + await expect(heading).toBeVisible(); + const editor = page.getByRole('application', { name: 'Text editor' }); + await expect(editor).toHaveAttribute('data-ready', 'true'); + await expect(editor.locator(':focus')).toHaveCount(1); + + await page.keyboard.press('ControlOrMeta+f'); + const findWidget = editor.locator('.find-widget.visible'); + await expect(findWidget).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(findWidget).toHaveCount(0); + await expect(heading).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(heading).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('Escape closes a search opened over the preview before the preview', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'preview-escape-search'); + const cleanupKeys = [`${prefix}layers.txt`]; + + try { + await putTextObject(client, credentials.bucket, `${prefix}layers.txt`, 'layers\n'); + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'layers.txt').dblclick(); + const heading = page.getByRole('heading', { name: 'layers.txt' }); + await expect(heading).toBeVisible(); + const editor = page.getByRole('application', { name: 'Text editor' }); + await expect(editor).toHaveAttribute('data-ready', 'true'); + + // Ctrl+K outside the editor opens the search on top of the preview. + await page + .getByRole('dialog') + .filter({ has: heading }) + .getByLabel('Close', { exact: true }) + .focus(); + await page.keyboard.press('ControlOrMeta+k'); + const searchInput = page.getByLabel('Search query'); + await expect(searchInput).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(searchInput).toBeHidden(); + await expect(heading).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(heading).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); }); diff --git a/e2e/storage/recent-items.spec.ts b/e2e/storage/recent-items.spec.ts index 9a53f2b7..5dd2284f 100644 --- a/e2e/storage/recent-items.spec.ts +++ b/e2e/storage/recent-items.spec.ts @@ -1,25 +1,23 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Page } from '../support/fixtures.js'; import { createS3Client, hasGarageCredentials, requireGarageCredentials } from '../support/garage.js'; import { - bucketRoute, connectAndOpenPrefix, - connectToStorage, deleteKnownKeys, putTextObject, rowByName, uniquePrefix, - waitForObjectsLoaded, waitForStorageConnected } from './helpers.js'; async function previewFile(page: Page, name: string) { await rowByName(page, name).dblclick(); await expect(page.getByRole('heading', { name })).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).last().click(); + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); + await expect(page.getByRole('heading', { name })).not.toBeVisible(); } test.describe('Storage S3 — Recent Items', () => { @@ -35,10 +33,7 @@ test.describe('Storage S3 — Recent Items', () => { test('tracks recently visited locations in the Recent Locations tab', async ({ page }) => { const credentials = requireGarageCredentials(); - await connectToStorage(page, credentials); - await expect(page).toHaveURL('/storage'); - await page.goto(bucketRoute(credentials.bucket)); - await waitForObjectsLoaded(page); + await connectAndOpenPrefix(page, credentials); await page.goto('/storage'); await waitForStorageConnected(page); @@ -62,7 +57,7 @@ test.describe('Storage S3 — Recent Items', () => { await connectAndOpenPrefix(page, credentials, prefix); await rowByName(page, 'recent.txt').dblclick(); await expect(page.getByRole('heading', { name: 'recent.txt' })).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).last().click(); + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); await page.goto('/storage'); await waitForStorageConnected(page); @@ -74,6 +69,7 @@ test.describe('Storage S3 — Recent Items', () => { }); test('removes a deleted file from Recent Files', async ({ page }, testInfo) => { + test.slow(); // multiple navigations + deletion; Firefox is slow in CI const credentials = requireGarageCredentials(); const client = createS3Client(credentials); const prefix = uniquePrefix(testInfo, 'recent-delete-file'); @@ -92,13 +88,12 @@ test.describe('Storage S3 — Recent Items', () => { await expect(page.locator('tbody').getByText('to-delete.txt')).toBeVisible(); // Delete the file via the UI - await page.goto(bucketRoute(credentials.bucket, prefix)); - await waitForObjectsLoaded(page); + await connectAndOpenPrefix(page, credentials, prefix); await page.getByRole('button', { name: 'Toggle selection mode' }).click(); await page.getByLabel('Select to-delete.txt').check(); await page.getByRole('button', { name: 'Delete', exact: true }).click(); await page.getByRole('button', { name: 'Delete permanently' }).click(); - await expect(page.getByText('This bucket is empty')).toBeVisible(); + await expect(page.getByText('This folder is empty')).toBeVisible(); // File should no longer appear in Recent Files await page.goto('/storage'); @@ -112,6 +107,7 @@ test.describe('Storage S3 — Recent Items', () => { test('removes files and location from recent lists when a directory is deleted', async ({ page }, testInfo) => { + test.slow(); // multiple navigations + directory deletion; Firefox is slow const credentials = requireGarageCredentials(); const client = createS3Client(credentials); const prefix = uniquePrefix(testInfo, 'recent-delete-dir'); @@ -134,13 +130,12 @@ test.describe('Storage S3 — Recent Items', () => { await expect(page.locator('tbody').getByText('sub', { exact: true })).toBeVisible(); // Delete the parent directory via the UI - await page.goto(bucketRoute(credentials.bucket, prefix)); - await waitForObjectsLoaded(page); + await connectAndOpenPrefix(page, credentials, prefix); await page.getByRole('button', { name: 'Toggle selection mode' }).click(); await page.getByLabel('Select sub').check(); await page.getByRole('button', { name: 'Delete', exact: true }).click(); await page.getByRole('button', { name: 'Delete permanently' }).click(); - await expect(page.getByText('This bucket is empty')).toBeVisible(); + await expect(page.getByText('This folder is empty')).toBeVisible(); // Neither the file nor the location should appear in the recent lists await page.goto('/storage'); diff --git a/e2e/storage/search.spec.ts b/e2e/storage/search.spec.ts new file mode 100644 index 00000000..1840b201 --- /dev/null +++ b/e2e/storage/search.spec.ts @@ -0,0 +1,227 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + bucketRoute, + connectAndOpenPrefix, + connectToStorage, + deleteKnownKeys, + putDirectoryMarker, + putTextObject, + uniquePrefix +} from './helpers.js'; + +test.describe('Storage Search', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('searches the current bucket, runs parallel sessions, and opens matching results', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'search'); + const connection = new URL(credentials.endpoint).hostname; + const directory = `${prefix}reports/`; + const file = `${directory}final-report.txt`; + + try { + await putDirectoryMarker(client, credentials.bucket, directory); + await putTextObject(client, credentials.bucket, file, 'search preview'); + await connectAndOpenPrefix(page, credentials, prefix); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + + await expect(page.getByText(/2 results/)).toBeVisible(); + await page.getByRole('button', { name: /reports.*\/reports\// }).click(); + await expect(page).toHaveURL(bucketRoute(connection, credentials.bucket, directory)); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByRole('button', { name: 'Parallel search' }).click(); + await expect(page.getByRole('navigation', { name: 'Search sessions' })).toBeVisible(); + await page.getByLabel('Search query').fill('final-report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await page + .getByRole('button', { name: /final-report\.txt.*\/reports\/final-report\.txt/ }) + .click(); + const previewDialog = page.getByRole('dialog').filter({ hasText: 'final-report.txt' }); + await expect(previewDialog).toContainText('final-report.txt'); + await previewDialog.getByLabel('Close', { exact: true }).click(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [directory, file]); + } + }); + + test('Escape closes an open date picker without closing the search', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + const searchInput = page.getByLabel('Search query'); + await expect(searchInput).toBeVisible(); + await page.getByText('Advanced options').click(); + await page.getByRole('button', { name: 'Add filter' }).click(); + await page.getByLabel('Filter field').last().selectOption('date'); + await page.getByRole('button', { name: 'Open date picker' }).last().click(); + const picker = page.getByRole('dialog', { name: 'Date picker' }); + await expect(picker).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(picker).toBeHidden(); + await expect(searchInput).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(searchInput).toBeHidden(); + }); + + test('rejects a max folder depth outside 1 to 20', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('report'); + await page.getByText('Advanced options').click(); + const depth = page.getByLabel('Max folder depth'); + const searchButton = page.getByRole('button', { name: 'Search', exact: true }); + + for (const value of ['25', '0']) { + await depth.fill(value); + await expect(page.getByText('Enter a whole number from 1 to 20.')).toBeVisible(); + await expect(depth).toHaveAttribute('aria-invalid', 'true'); + await expect(searchButton).toBeDisabled(); + } + + await depth.fill('3'); + await expect(page.getByText('Enter a whole number from 1 to 20.')).toBeHidden(); + await expect(searchButton).toBeEnabled(); + }); + + test('searches all buckets from the landing page', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'landing-search'); + const file = `${prefix}landing-report.txt`; + + try { + await putTextObject(client, credentials.bucket, file, 'landing search'); + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('landing-report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + + await expect(page.getByRole('button', { name: 'landing-report.txt' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [file]); + } + }); + + test('paginates search results', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'paginate-search'); + const keys: string[] = []; + + try { + for (let index = 1; index <= 30; index += 1) { + const key = `${prefix}report-${String(index).padStart(2, '0')}.txt`; + keys.push(key); + await putTextObject(client, credentials.bucket, key, `report ${index}`); + } + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('report-'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await page.getByLabel('Items per page').selectOption('25'); + + await expect(page.getByText('Page 1', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'report-01.txt' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'report-26.txt' })).not.toBeVisible(); + + await page.getByRole('button', { name: 'Next page' }).click(); + await expect(page.getByText('Page 2', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'report-26.txt' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'report-01.txt' })).not.toBeVisible(); + await page.getByLabel('Search query').fill('report-01.txt'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect(page.getByRole('button', { name: 'report-01.txt' })).toBeVisible(); + await expect(page.getByText('Page 1', { exact: true })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, keys); + } + }); + + test('filters and selects buckets via the bucket dropdown', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'bucket-scope'); + const file = `${prefix}scoped-report.txt`; + + try { + await putTextObject(client, credentials.bucket, file, 'scoped search'); + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + + const trigger = page.getByRole('button', { name: 'all buckets', exact: true }); + await expect(trigger).toBeVisible(); + await trigger.click(); + + await page.getByLabel('Filter buckets').fill(credentials.bucket); + await page + .getByRole('button', { name: `Include ${credentials.bucket} in search`, exact: true }) + .click(); + await expect( + page.getByRole('button', { name: credentials.bucket, exact: true }) + ).toBeVisible(); + + await page.getByLabel('Search query').fill('scoped-report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect(page.getByRole('button', { name: 'scoped-report.txt' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [file]); + } + }); + + test('shows recent searches in the recent view and reuses one', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'recent'); + const file = `${prefix}recent-report.txt`; + + try { + await putTextObject(client, credentials.bucket, file, 'recent search'); + await connectAndOpenPrefix(page, credentials, prefix); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('recent-report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect(page.getByRole('button', { name: 'recent-report.txt' }).first()).toBeVisible(); + + await page.getByRole('button', { name: 'Close search' }).click(); + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByRole('tab', { name: 'Recent', exact: true }).click(); + await expect(page.getByRole('button', { name: /recent-report/ }).first()).toBeVisible(); + + await page + .getByRole('button', { name: /recent-report/ }) + .first() + .click(); + await expect(page.getByLabel('Search query')).toHaveValue('recent-report'); + } finally { + await deleteKnownKeys(client, credentials.bucket, [file]); + } + }); +}); diff --git a/e2e/storage/tabs.spec.ts b/e2e/storage/tabs.spec.ts new file mode 100644 index 00000000..54da203e --- /dev/null +++ b/e2e/storage/tabs.spec.ts @@ -0,0 +1,356 @@ +import { test, expect } from '../support/fixtures.js'; +import { hasGarageCredentials, requireGarageCredentials } from '../support/garage.js'; +import { waitForHydration } from '../support/helpers.js'; +import { + connectToStorage, + connectAndOpenPrefix, + bucketRoute, + seedStorageTabsState +} from './helpers.js'; + +// ── Tab bar ─────────────────────────────────────────────────────────────────── + +test.describe('Storage — Explorer tab bar', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('tab bar is visible when there is only one tab', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await expect(page.getByRole('tablist', { name: 'Explorer tabs' })).toBeVisible(); + }); + + test('add tab via + button shows the tab bar with two tabs', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await expect(tablist).toBeVisible(); + await expect(tablist.getByRole('tab')).toHaveCount(2); + // Newly added tab becomes active + await expect(tablist.getByRole('tab').nth(1)).toHaveAttribute('aria-selected', 'true'); + }); + + test('clicking a tab switches the active tab', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + const tabs = tablist.getByRole('tab'); + + // Second tab is currently active; click the first + await tabs.nth(0).click(); + await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true'); + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'false'); + }); + + test('tabs retain separate bucket locations', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await page.getByRole('button', { name: 'New Tab' }).click(); + await page.getByRole('link', { name: credentials.bucket, exact: true }).last().click(); + + await expect(tablist.getByRole('tab').nth(1)).toHaveAttribute('title', credentials.bucket); + await tablist.getByRole('tab').nth(0).click(); + await expect(page).toHaveURL( + bucketRoute(new URL(credentials.endpoint).hostname, credentials.bucket) + ); + }); + + test('closing a tab leaves the tab bar visible when one remains', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await expect(tablist.getByRole('tab')).toHaveCount(2); + + // Hover the tab to make its sibling close button visible (it is opacity-0 by default). + // The button cannot be nested in the tab button without producing invalid HTML. + const firstTab = tablist.getByRole('tab').nth(0); + await firstTab.hover(); + await tablist.getByRole('button', { name: 'Close tab' }).first().click(); + + await expect(tablist).toBeVisible(); + await expect(tablist.getByRole('tab')).toHaveCount(1); + }); + + test('close button is absent when only one tab exists', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await expect(page.getByRole('button', { name: 'Close tab' })).toHaveCount(0); + }); + + test('middle-click on a tab closes it', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await expect(tablist.getByRole('tab')).toHaveCount(2); + + await tablist.getByRole('tab').nth(1).click({ button: 'middle' }); + + await expect(tablist).toBeVisible(); + }); + + test('double-click on a tab starts inline rename', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await tablist.getByRole('tab').nth(0).dblclick(); + + const input = page.getByRole('textbox', { name: 'Rename tab' }); + await expect(input).toBeVisible(); + + await input.fill('My renamed tab'); + await input.press('Enter'); + + await expect(tablist.getByRole('tab').nth(0)).toContainText('My renamed tab'); + }); + + test('Escape cancels rename without changing the label', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + const firstTab = tablist.getByRole('tab').nth(0); + + // The initial label is the bucket name (prefix is empty) + const originalLabel = credentials.bucket; + + await firstTab.dblclick(); + await page.getByRole('textbox', { name: 'Rename tab' }).fill('discarded name'); + await page.keyboard.press('Escape'); + + // Input disappears and the original label is preserved + await expect(page.getByRole('textbox', { name: 'Rename tab' })).not.toBeVisible(); + await expect(firstTab).toHaveAttribute('title', originalLabel); + }); + + test('right-click opens a context menu with Rename and Close options', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await tablist.getByRole('tab').nth(0).click({ button: 'right' }); + + const tabmenu = page.getByRole('menu', { name: 'Tab options' }); + await expect(tabmenu).toBeVisible(); + await expect(tabmenu.getByRole('menuitem', { name: 'Rename tab' })).toBeVisible(); + await expect(tabmenu.getByRole('menuitem', { name: 'Close tab' })).toBeVisible(); + }); + + test('rename via right-click context menu', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await tablist.getByRole('tab').nth(0).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Rename tab' }).click(); + + const input = page.getByRole('textbox', { name: 'Rename tab' }); + await expect(input).toBeVisible(); + await input.fill('Context menu rename'); + await input.press('Enter'); + + await expect(tablist.getByRole('tab').nth(0)).toContainText('Context menu rename'); + }); + + test('close tab via right-click context menu', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await expect(tablist.getByRole('tab')).toHaveCount(2); + + await tablist.getByRole('tab').nth(0).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Close tab' }).click(); + + await expect(tablist).toBeVisible(); + await expect(tablist.getByRole('tab')).toHaveCount(1); + }); + + test('New Tab button adds a tab', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + // Tab bar must appear with 2 tabs + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await expect(tablist).toBeVisible(); + await expect(tablist.getByRole('tab')).toHaveCount(2); + }); +}); + +// ── Restore banner ──────────────────────────────────────────────────────────── + +test.describe('Storage — Restore tabs banner', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('banner appears when there are 2 or more saved tabs', async ({ page }) => { + const credentials = requireGarageCredentials(); + + await seedStorageTabsState(page, { + tabs: [ + { id: 'tab-a', label: credentials.bucket, bucket: credentials.bucket, prefix: '' }, + { id: 'tab-b', label: 'subfolder', bucket: credentials.bucket, prefix: 'subfolder/' } + ], + activeTabId: 'tab-a' + }); + + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Restore tabs' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Dismiss' })).toBeVisible(); + }); + + test('banner message includes the saved tab count', async ({ page }) => { + const credentials = requireGarageCredentials(); + + await seedStorageTabsState(page, { + tabs: [ + { id: 'tab-a', label: credentials.bucket, bucket: credentials.bucket, prefix: '' }, + { id: 'tab-b', label: 'folder-1', bucket: credentials.bucket, prefix: 'folder-1/' }, + { id: 'tab-c', label: 'folder-2', bucket: credentials.bucket, prefix: 'folder-2/' } + ], + activeTabId: 'tab-a' + }); + + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + await expect(page.getByRole('alert')).toContainText('3'); + }); + + test('banner does not appear when there is only one saved tab', async ({ page }) => { + const credentials = requireGarageCredentials(); + + await seedStorageTabsState(page, { + tabs: [{ id: 'tab-a', label: credentials.bucket, bucket: credentials.bucket, prefix: '' }], + activeTabId: 'tab-a' + }); + + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + await expect(page.getByRole('button', { name: 'Restore tabs' })).not.toBeVisible(); + }); + + test('Dismiss hides the banner', async ({ page }) => { + const credentials = requireGarageCredentials(); + + await seedStorageTabsState(page, { + tabs: [ + { id: 'tab-a', label: credentials.bucket, bucket: credentials.bucket, prefix: '' }, + { id: 'tab-b', label: 'subfolder', bucket: credentials.bucket, prefix: 'subfolder/' } + ], + activeTabId: 'tab-a' + }); + + await connectToStorage(page, credentials); + await expect(page).toHaveURL('/storage'); + + await page.getByRole('button', { name: 'Dismiss' }).click(); + + await expect(page.getByRole('button', { name: 'Restore tabs' })).not.toBeVisible(); + }); + + test('Dismiss does not clear saved tabs — banner reappears on re-visit', async ({ page }) => { + test.slow(); // navigates to / (Trino editor) which is slow in Firefox + const credentials = requireGarageCredentials(); + + // Step 1: navigate to bucket and add a second tab via natural interaction + await connectAndOpenPrefix(page, credentials); + + await page.getByRole('button', { name: 'New Tab' }).click(); + await expect(page.getByRole('tablist', { name: 'Explorer tabs' }).getByRole('tab')).toHaveCount( + 2 + ); + + // Step 2: navigate back to /storage — banner should appear + await page.goto('/storage'); + await waitForHydration(page); + await expect(page.getByRole('button', { name: 'Restore tabs' })).toBeVisible(); + + // Step 3: dismiss + await page.getByRole('button', { name: 'Dismiss' }).click(); + await expect(page.getByRole('button', { name: 'Restore tabs' })).not.toBeVisible(); + + // Step 4: navigate to dashboard and back — banner must reappear because + // localStorage was not cleared by Dismiss + await page.goto('/'); + await waitForHydration(page); + await page.goto('/storage'); + await waitForHydration(page); + + await expect(page.getByRole('button', { name: 'Restore tabs' })).toBeVisible(); + }); + + test('Restore tabs navigates to the active-tab bucket and loads all tabs', async ({ page }) => { + test.slow(); // page.goto('/storage') in Firefox is slow + const credentials = requireGarageCredentials(); + + // Step 1: navigate to bucket and add a second tab via natural interaction + await connectAndOpenPrefix(page, credentials); + await page.getByRole('button', { name: 'New Tab' }).click(); + await expect(page.getByRole('tablist', { name: 'Explorer tabs' }).getByRole('tab')).toHaveCount( + 2 + ); + + // Step 2: navigate back to /storage — banner appears + await page.goto('/storage'); + await waitForHydration(page); + await expect(page.getByRole('button', { name: 'Restore tabs' })).toBeVisible(); + + // Step 3: click Restore tabs + await page.getByRole('button', { name: 'Restore tabs' }).click(); + + // Should navigate to the bucket route + await expect(page).toHaveURL( + bucketRoute(new URL(credentials.endpoint).hostname, credentials.bucket) + ); + + // Step 4: tab bar must render with 2 restored tabs + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await expect(tablist).toBeVisible(); + await expect(tablist.getByRole('tab')).toHaveCount(2); + }); +}); diff --git a/e2e/storage/toast.spec.ts b/e2e/storage/toast.spec.ts new file mode 100644 index 00000000..bbf53ef6 --- /dev/null +++ b/e2e/storage/toast.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + connectAndOpenPrefix, + deleteKnownKeys, + putTextObject, + rowByName, + uniquePrefix +} from './helpers.js'; + +test.describe('Storage S3 — Toast notifications', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('copying a file shows a success toast with a dismiss button', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'toast-copy'); + const srcFile = `${prefix}toast-test.txt`; + const cleanupKeys = [srcFile]; + + try { + await putTextObject(client, credentials.bucket, srcFile, 'toast test content'); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Copy the file via context menu — triggers a success toast + await rowByName(page, 'toast-test.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + + // Verify the success toast appears with the correct role + const toast = page.getByRole('alert').filter({ hasText: /copied/i }); + await expect(toast).toBeVisible(); + + // Verify the toast has a Dismiss button + const dismissBtn = toast.getByRole('button', { name: 'Dismiss' }); + await expect(dismissBtn).toBeVisible(); + + // Click Dismiss and verify the toast disappears + await dismissBtn.click(); + await expect(toast).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); +}); diff --git a/e2e/storage/tooltip.spec.ts b/e2e/storage/tooltip.spec.ts new file mode 100644 index 00000000..6094f41b --- /dev/null +++ b/e2e/storage/tooltip.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '../support/fixtures.js'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { connectAndOpenPrefix } from './helpers.js'; + +test.describe('Storage S3 — Tooltips', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + test('hovering over a sidebar bucket name shows a tooltip', async ({ page }, _testInfo) => { + const credentials = requireGarageCredentials(); + createS3Client(credentials); + + await connectAndOpenPrefix(page, credentials, ''); + + // Find the sidebar bucket link for the configured bucket + const bucketLink = page.locator('nav a').filter({ hasText: credentials.bucket }).first(); + await expect(bucketLink).toBeVisible(); + + // Hover over the bucket link to trigger onmouseenter → showTooltip + await bucketLink.hover(); + + // Verify a tooltip element appears with the bucket name + const tooltip = page.getByRole('tooltip'); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText(credentials.bucket); + }); +}); diff --git a/e2e/storage/transfer.spec.ts b/e2e/storage/transfer.spec.ts new file mode 100644 index 00000000..cc2c9179 --- /dev/null +++ b/e2e/storage/transfer.spec.ts @@ -0,0 +1,600 @@ +import { test, expect, type Page, type TestInfo } from '../support/fixtures.js'; +import { ListObjectsV2Command, type S3Client } from '@aws-sdk/client-s3'; +import { + createGarageBucketCredentials, + createS3Client, + hasGarageAdmin, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + bucketRoute, + connectAndOpenPrefix, + deleteKnownKeys, + getObjectText, + objectExists, + putDirectoryMarker, + putTextObject, + rowByName, + uniqueBucketName, + waitForObjectsLoaded, + uniquePrefix +} from './helpers.js'; + +/** Every key under `prefix`, sorted. */ +async function listKeys(client: S3Client, bucket: string, prefix: string): Promise { + const keys: string[] = []; + let token: string | undefined; + do { + const page = await client.send( + new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ContinuationToken: token }) + ); + for (const object of page.Contents ?? []) if (object.Key) keys.push(object.Key); + token = page.IsTruncated ? page.NextContinuationToken : undefined; + } while (token); + return keys.sort(); +} + +/** Objects in a folder that a cancel test moves; enough that copying takes far longer than a cancel. */ +const CANCEL_OBJECTS = 1000; + +async function putMany(client: S3Client, bucket: string, prefix: string, count: number) { + for (let start = 0; start < count; start += 50) { + await Promise.all( + Array.from({ length: Math.min(50, count - start) }, (_, i) => + putTextObject(client, bucket, `${prefix}${start + i}.txt`, String(start + i)) + ) + ); + } +} + +async function deletePrefix(client: S3Client, bucket: string, prefix: string) { + await deleteKnownKeys(client, bucket, await listKeys(client, bucket, prefix)); +} + +/** Open a folder in-app (keeping the in-memory clipboard) and wait for its listing. */ +async function openFolder(page: Page, name: string) { + const url = page.url(); + await rowByName(page, name).dblclick(); + await expect(page).not.toHaveURL(url); + await waitForObjectsLoaded(page); +} + +/** Open a bucket from the sidebar in-app and wait for its listing. */ +async function openBucket(page: Page, bucket: string) { + await page.getByRole('link', { name: bucket, exact: true }).last().click(); + await expect(page).toHaveURL((url) => + url.pathname.replace(/\/$/, '').endsWith(`/${encodeURIComponent(bucket)}`) + ); + await waitForObjectsLoaded(page); +} + +/** Go one folder up via the ".." row, keeping the in-memory clipboard. */ +async function goUp(page: Page) { + await page.locator('tbody tr').first().click(); +} + +/** + * Paste with Ctrl+V. Focus is cleared first (the shortcuts also apply when + * nothing has focus): clicking the table could hit a row and navigate. + */ +async function pasteHere(page: Page) { + await waitForObjectsLoaded(page); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + await page.keyboard.press('Control+v'); +} + +/** The refusal dialog is named by its heading and has no empty paragraphs. */ +async function expectNamedRefusalDialog(page: Page) { + const dialog = page.getByRole('dialog', { name: 'Cannot be transferred' }); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('p').filter({ hasNotText: /\S/ })).toHaveCount(0); +} + +async function contextAction(page: Page, name: string, action: string) { + await rowByName(page, name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: action, exact: true }).click(); +} + +/** + * Call a storage API from the page with the active connection, as the app does. + * The connection ID is taken from the app's own listing request. + */ +async function connectionHeader(page: Page): Promise> { + const request = page.waitForRequest((r) => r.url().includes('/api/storage/list')); + await page.reload(); + const headers = (await request).headers(); + return { 'x-storage-connection-id': headers['x-storage-connection-id'] }; +} + +test.describe('Storage S3 — Transfers', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('keep both pastes a conflicting folder as a numbered copy with its full structure', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'keep-both-folder'); + + try { + await putDirectoryMarker(client, credentials.bucket, `${prefix}src/docs/`); + await putTextObject(client, credentials.bucket, `${prefix}src/docs/a.txt`, 'a'); + await putTextObject(client, credentials.bucket, `${prefix}src/docs/sub/b.txt`, 'b'); + await putTextObject(client, credentials.bucket, `${prefix}dest/docs/old.txt`, 'old'); + + await connectAndOpenPrefix(page, credentials, `${prefix}src/`); + await contextAction(page, 'docs', 'Copy'); + await goUp(page); + await openFolder(page, 'dest'); + await expect(rowByName(page, 'docs')).toBeVisible(); + await pasteHere(page); + + const dialog = page.getByRole('dialog'); + await expect(page.getByRole('dialog', { name: 'Files already exist' })).toBeVisible(); + await dialog.getByRole('button', { name: 'Keep both' }).click(); + await dialog.getByRole('button', { name: 'Paste', exact: true }).click(); + + await expect(page.getByText('1 item pasted')).toBeVisible(); + expect(await listKeys(client, credentials.bucket, `${prefix}dest/`)).toEqual([ + `${prefix}dest/docs (1)/`, + `${prefix}dest/docs (1)/a.txt`, + `${prefix}dest/docs (1)/sub/b.txt`, + `${prefix}dest/docs/old.txt` + ]); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test('a cut folder pasted twice keeps its structure (F2)', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'cut-twice'); + + try { + await putTextObject(client, credentials.bucket, `${prefix}docs/a.txt`, 'a'); + await putTextObject(client, credentials.bucket, `${prefix}docs/sub/b.txt`, 'b'); + await putDirectoryMarker(client, credentials.bucket, `${prefix}d1/`); + await putDirectoryMarker(client, credentials.bucket, `${prefix}d2/`); + + await connectAndOpenPrefix(page, credentials, prefix); + await contextAction(page, 'docs', 'Cut'); + await openFolder(page, 'd1'); + await pasteHere(page); + await expect(page.getByText('1 item pasted')).toBeVisible(); + await expect(rowByName(page, 'docs')).toBeVisible(); + + await goUp(page); + await openFolder(page, 'd2'); + await pasteHere(page); + await expect(page.getByText('1 item pasted').last()).toBeVisible(); + + await expect + .poll(() => listKeys(client, credentials.bucket, `${prefix}d2/`)) + .toEqual([`${prefix}d2/`, `${prefix}d2/docs/a.txt`, `${prefix}d2/docs/sub/b.txt`]); + expect(await listKeys(client, credentials.bucket, `${prefix}d1/docs/`)).toEqual([ + `${prefix}d1/docs/a.txt`, + `${prefix}d1/docs/sub/b.txt` + ]); + expect(await listKeys(client, credentials.bucket, `${prefix}docs/`)).toEqual([]); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test('the dialog shows refused items with their reason (F5)', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'refusals'); + + try { + await putTextObject(client, credentials.bucket, `${prefix}docs/sub/x.txt`, 'x'); + await putTextObject(client, credentials.bucket, `${prefix}a.txt`, 'a'); + + await connectAndOpenPrefix(page, credentials, prefix); + const dialog = page.getByRole('dialog'); + + // A folder into its own subtree. + await contextAction(page, 'docs', 'Copy'); + await openFolder(page, 'docs'); + await openFolder(page, 'sub'); + await pasteHere(page); + await expect(dialog.getByText('Cannot be transferred').first()).toBeVisible(); + await expect(dialog.getByText('A folder cannot be placed inside itself.')).toBeVisible(); + await expectNamedRefusalDialog(page); + await dialog.getByRole('button', { name: 'Close' }).click(); + + // A cut pasted into its own folder. + await goUp(page); + await expect(rowByName(page, 'sub')).toBeVisible(); + await goUp(page); + await expect(rowByName(page, 'a.txt')).toBeVisible(); + await contextAction(page, 'a.txt', 'Cut'); + await pasteHere(page); + await expect(dialog.getByText('It is already in this location.')).toBeVisible(); + await expectNamedRefusalDialog(page); + await dialog.getByRole('button', { name: 'Close' }).click(); + + expect(await listKeys(client, credentials.bucket, prefix)).toEqual([ + `${prefix}a.txt`, + `${prefix}docs/sub/x.txt` + ]); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test('a move with mixed outcomes shows one toast with counts (#9)', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'mixed-feedback'); + const names = ['f1.txt', 'f2.txt', 'f3.txt', 'f4.txt', 'f5.txt']; + + try { + for (const name of names) { + await putTextObject(client, credentials.bucket, `${prefix}${name}`, name); + } + await putDirectoryMarker(client, credentials.bucket, `${prefix}target/`); + + await connectAndOpenPrefix(page, credentials, prefix); + await page.keyboard.press('Control+a'); + await rowByName(page, 'target').click({ modifiers: ['Control'] }); + await page.keyboard.press('Control+x'); + + // Four sources disappear before the paste: they fail, one moves. + await deleteKnownKeys( + client, + credentials.bucket, + names.slice(1).map((name) => `${prefix}${name}`) + ); + await openFolder(page, 'target'); + await pasteHere(page); + + const toast = page.getByRole('alert').filter({ hasText: 'Paste finished' }); + await expect(toast).toHaveCount(1); + await expect(toast).toContainText('1 done, 4 failed.'); + await expect(toast).toContainText('+ 1 more'); + expect(await objectExists(client, credentials.bucket, `${prefix}target/f1.txt`)).toBe(true); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test('renaming in a folder with an empty segment keeps it there (F12, R3)', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'empty-segment'); + + try { + await putTextObject(client, credentials.bucket, `${prefix}a//b.txt`, 'b'); + await putTextObject(client, credentials.bucket, `${prefix}a/c.txt`, 'other c'); + + // The browser cannot open the nameless folder `a//` (its route has an empty path + // segment), so the rename is sent the way the rename modal sends it. + await connectAndOpenPrefix(page, credentials, prefix); + const headers = await connectionHeader(page); + const status = await page.evaluate( + async ({ headers, bucket, key }) => { + const res = await fetch('/api/storage/transfer', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + op: 'rename', + jobId: crypto.randomUUID(), + source: { bucket }, + items: [{ sourceKey: key, name: 'c.txt' }] + }) + }); + const lines = (await res.text()) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + return lines.find((line) => line.type === 'item-done').status; + }, + { headers, bucket: credentials.bucket, key: `${prefix}a//b.txt` } + ); + expect(status).toBe('done'); + + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}a//c.txt`)) + .toBe(true); + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}a//b.txt`)) + .toBe(false); + expect(await getObjectText(client, credentials.bucket, `${prefix}a/c.txt`)).toBe('other c'); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test('refuses a drop of items from another connection', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'other-connection'); + + try { + await putTextObject(client, credentials.bucket, `${prefix}a.txt`, 'a'); + await putDirectoryMarker(client, credentials.bucket, `${prefix}target/`); + await connectAndOpenPrefix(page, credentials, prefix); + + // Within one window a connection switch reloads the page, so items from another + // connection arrive by dragging from a second window. Playwright cannot drag + // between pages, so the drop carries that window's drag data directly. + await rowByName(page, 'target').evaluate( + (row, { bucket, key }) => { + const data = new DataTransfer(); + data.setData('application/x-storage-keys', JSON.stringify([key])); + data.setData( + 'application/x-storage-source', + JSON.stringify({ connectionId: 'another-connection', bucket }) + ); + row.dispatchEvent( + new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }) + ); + }, + { bucket: credentials.bucket, key: `${prefix}a.txt` } + ); + + await expect(page.getByText(/belong to another storage connection/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Move', exact: true })).toHaveCount(0); + expect(await listKeys(client, credentials.bucket, prefix)).toEqual([ + `${prefix}a.txt`, + `${prefix}target/` + ]); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test('cancelling a running move keeps every original (#20)', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'cancel-move'); + + try { + await putMany(client, credentials.bucket, `${prefix}many/`, CANCEL_OBJECTS); + await connectAndOpenPrefix(page, credentials, prefix); + const headers = await connectionHeader(page); + + // Cancel as soon as the request is accepted. Wherever the cancel lands before the + // deletes start, the item ends cancelled with every original kept; with one object + // at a time (.env.test) the deletes start only after all copies. + const events = await page.evaluate( + async ({ headers, bucket, prefix }) => { + const jobId = crypto.randomUUID(); + const res = await fetch('/api/storage/transfer', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + op: 'move', + jobId, + source: { bucket }, + dest: { bucket, prefix: `${prefix}target/` }, + items: [{ sourceKey: `${prefix}many/` }] + }) + }); + await fetch(`/api/storage/copy/job/${jobId}`, { method: 'DELETE', headers }); + return (await res.text()) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + }, + { headers, bucket: credentials.bucket, prefix } + ); + + expect(events.at(-1).type).toBe('complete'); + const itemDone = events.find((event) => event.type === 'item-done'); + expect(itemDone).toMatchObject({ status: 'cancelled', deletedOriginals: 0 }); + expect(itemDone.copied).toBeLessThan(CANCEL_OBJECTS); + expect(await listKeys(client, credentials.bucket, `${prefix}many/`)).toHaveLength( + CANCEL_OBJECTS + ); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test('the Cancel button stops a running paste and keeps the originals', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'cancel-button'); + + try { + await putMany(client, credentials.bucket, `${prefix}many/`, CANCEL_OBJECTS); + await putDirectoryMarker(client, credentials.bucket, `${prefix}target/`); + await connectAndOpenPrefix(page, credentials, prefix); + + await contextAction(page, 'many', 'Cut'); + await openFolder(page, 'target'); + await pasteHere(page); + await page.getByRole('button', { name: 'Operations', exact: true }).click(); + await page.getByRole('button', { name: 'Cancel operation' }).first().click(); + + const toast = page.getByRole('alert').filter({ hasText: '1 cancelled' }); + await expect(toast).toBeVisible({ timeout: 15_000 }); + expect(await listKeys(client, credentials.bucket, `${prefix}many/`)).toHaveLength( + CANCEL_OBJECTS + ); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test('the API plans, rejects bad requests and always ends its stream with complete', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'api'); + + try { + await putTextObject(client, credentials.bucket, `${prefix}a.txt`, 'a'); + await putTextObject(client, credentials.bucket, `${prefix}dest/a.txt`, 'existing'); + await connectAndOpenPrefix(page, credentials, prefix); + const headers = await connectionHeader(page); + + const result = await page.evaluate( + async ({ headers, bucket, prefix }) => { + const post = (path: string, body: unknown) => + fetch(path, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const plan = await post('/api/storage/transfer/plan', { + op: 'copy', + source: { bucket, keys: [`${prefix}a.txt`] }, + dest: { bucket, prefix: `${prefix}dest/` } + }); + const invalid = await post('/api/storage/transfer', { op: 'copy' }); + const transfer = { + op: 'copy', + jobId: crypto.randomUUID(), + source: { bucket }, + dest: { bucket, prefix: `${prefix}dest/` }, + items: [{ sourceKey: `${prefix}a.txt` }] + }; + const first = await post('/api/storage/transfer', transfer); + const lines = (await first.text()) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const reused = await post('/api/storage/transfer', transfer); + return { + plan: await plan.json(), + invalidStatus: invalid.status, + lines, + reusedStatus: reused.status + }; + }, + { headers, bucket: credentials.bucket, prefix } + ); + + expect(result.plan.items).toEqual([ + { + sourceKey: `${prefix}a.txt`, + kind: 'file', + destKey: `${prefix}dest/a.txt`, + conflict: 'file', + suggestedName: 'a (1).txt' + } + ]); + expect(result.invalidStatus).toBe(400); + expect(result.reusedStatus).toBe(409); + // Without a policy the conflicting item is refused, and the stream still completes. + expect(result.lines.map((line: { type: string }) => line.type)).toEqual([ + 'item-done', + 'complete' + ]); + expect(result.lines[0]).toMatchObject({ status: 'refused', reason: 'conflict' }); + expect(await getObjectText(client, credentials.bucket, `${prefix}dest/a.txt`)).toBe( + 'existing' + ); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + } + }); + + test.describe('across buckets', () => { + test.beforeEach(() => { + test.skip(!hasGarageAdmin(), 'Skipped: Garage admin API is unavailable for bucket setup'); + }); + + async function secondBucket(testInfo: TestInfo) { + const credentials = requireGarageCredentials(); + const bucket = uniqueBucketName(testInfo, 'xfer'); + await createGarageBucketCredentials(credentials, { + bucketName: bucket, + keyName: `xfer-${testInfo.project.name}-${crypto.randomUUID()}`, + permissions: { owner: false, read: true, write: true }, + ownerAccessKeyId: credentials.accessKeyId + }); + return bucket; + } + + test('copies and moves into another bucket', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'cross-bucket'); + const other = await secondBucket(testInfo); + + try { + await putTextObject(client, credentials.bucket, `${prefix}copy-me.txt`, 'copied'); + await putTextObject(client, credentials.bucket, `${prefix}move-me.txt`, 'moved'); + + await connectAndOpenPrefix(page, credentials, prefix); + await contextAction(page, 'copy-me.txt', 'Copy'); + await openBucket(page, other); + await pasteHere(page); + await expect(page.getByText('1 item pasted')).toBeVisible(); + + await page.goto( + bucketRoute(new URL(credentials.endpoint).hostname, credentials.bucket, prefix) + ); + await expect(rowByName(page, 'move-me.txt')).toBeVisible(); + await contextAction(page, 'move-me.txt', 'Cut'); + await openBucket(page, other); + await pasteHere(page); + await expect(page.getByText('1 item pasted').last()).toBeVisible(); + + await expect.poll(() => objectExists(client, other, 'move-me.txt')).toBe(true); + expect(await getObjectText(client, other, 'copy-me.txt')).toBe('copied'); + expect(await getObjectText(client, other, 'move-me.txt')).toBe('moved'); + expect(await objectExists(client, credentials.bucket, `${prefix}copy-me.txt`)).toBe(true); + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}move-me.txt`)) + .toBe(false); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + await deletePrefix(client, other, ''); + } + }); + + test("dropping onto another bucket's tab moves into that bucket (R5)", async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'tab-drop'); + const other = await secondBucket(testInfo); + + try { + await putTextObject(client, credentials.bucket, `${prefix}drag-me.txt`, 'dragged'); + + await connectAndOpenPrefix(page, credentials, prefix); + const tablist = page.getByRole('tablist', { name: 'Explorer tabs' }); + await page.getByRole('button', { name: 'New Tab' }).click(); + await page.getByRole('link', { name: other, exact: true }).last().click(); + await expect(tablist.getByRole('tab').nth(1)).toHaveAttribute('title', other); + await tablist.getByRole('tab').nth(0).click(); + await expect(rowByName(page, 'drag-me.txt')).toBeVisible(); + + await rowByName(page, 'drag-me.txt').dragTo(tablist.getByRole('tab').nth(1)); + await page.getByRole('button', { name: 'Move', exact: true }).click(); + + await expect.poll(() => objectExists(client, other, 'drag-me.txt')).toBe(true); + await expect + .poll(() => objectExists(client, credentials.bucket, `${prefix}drag-me.txt`)) + .toBe(false); + expect(await objectExists(client, credentials.bucket, 'drag-me.txt')).toBe(false); + } finally { + await deletePrefix(client, credentials.bucket, prefix); + await deletePrefix(client, other, ''); + } + }); + }); +}); diff --git a/e2e/storage/upload.spec.ts b/e2e/storage/upload.spec.ts index 079f9816..e8c0b050 100644 --- a/e2e/storage/upload.spec.ts +++ b/e2e/storage/upload.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Route } from '../support/fixtures.js'; import { createS3Client, hasGarageCredentials, @@ -15,9 +15,11 @@ import { getObjectText, headObject, modalBox, + putDirectoryMarker, putTextObject, rowByName, - uniquePrefix + uniquePrefix, + waitForObjectsLoaded } from './helpers.js'; test.describe('Storage S3 — Upload', () => { @@ -30,6 +32,77 @@ test.describe('Storage S3 — Upload', () => { ); }); + for (const target of ['empty listing', 'folder row', 'browser toolbar']) { + test(`uploads files dropped on the ${target} to the current prefix`, async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'drop-upload'); + const fixture = createTextUploadFixture(testInfo.title); + const key = `${prefix}${fixture.name}`; + const nestedKey = `${prefix}child/existing.txt`; + + try { + if (target === 'folder row') { + await putTextObject(client, credentials.bucket, nestedKey, 'existing child'); + } + await connectAndOpenPrefix(page, credentials, prefix); + + const browser = page.getByTestId('storage-file-browser'); + const dropTarget = + target === 'folder row' + ? rowByName(page, 'child') + : target === 'empty listing' + ? page.getByTestId('storage-object-list').locator('tbody tr').last() + : browser.getByRole('button', { name: 'Upload', exact: true }); + const transfer = await page.evaluateHandle( + ({ name, text }) => { + const dt = new DataTransfer(); + dt.items.add(new File([text], name, { type: 'text/plain' })); + dt.items.add(new File(['second file'], 'second.txt', { type: 'text/plain' })); + return dt; + }, + { name: fixture.name, text: fixture.fullText } + ); + try { + await dropTarget.dispatchEvent('dragenter', { dataTransfer: transfer }); + await dropTarget.dispatchEvent('dragover', { dataTransfer: transfer }); + await expect(page.getByTestId('storage-upload-drop-overlay')).toBeVisible(); + await dropTarget.dispatchEvent('dragleave', { dataTransfer: transfer }); + await expect(page.getByTestId('storage-upload-drop-overlay')).not.toBeVisible(); + await dropTarget.dispatchEvent('dragenter', { dataTransfer: transfer }); + await dropTarget.dispatchEvent('drop', { dataTransfer: transfer }); + await expect(page.getByTestId('storage-upload-drop-overlay')).not.toBeVisible(); + + const uploadModal = modalBox(page); + await expect(uploadModal.getByText(fixture.name, { exact: true })).toBeVisible(); + await expect(uploadModal.getByText('second.txt', { exact: true })).toBeVisible(); + await expect(uploadModal.getByText(`Uploading to: ${prefix}`)).toBeVisible(); + // A drop selects files for review; it must not start an upload yet. + await expect( + uploadModal.getByRole('button', { name: 'Upload', exact: true }) + ).toBeVisible(); + await uploadModal.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + + await dropTarget.dispatchEvent('drop', { dataTransfer: transfer }); + await expect(uploadModal.getByText(fixture.name, { exact: true })).toBeVisible(); + await expect(uploadModal.getByText('second.txt', { exact: true })).toBeVisible(); + await uploadModal.getByRole('button', { name: 'Upload', exact: true }).click(); + await expect(uploadModal.getByText('2 uploaded · 0 skipped · 0 failed')).toBeVisible(); + await uploadModal.getByRole('button', { name: 'Done' }).click(); + await expect(rowByName(page, fixture.name)).toBeVisible(); + await expect(await getObjectText(client, credentials.bucket, key)).toBe(fixture.fullText); + } finally { + await transfer.dispose(); + } + } finally { + await deleteKnownKeys(client, credentials.bucket, [key, `${prefix}second.txt`, nestedKey]); + } + }); + } + test('uploads realistic faker-generated text, csv, and image files', async ({ page }, testInfo) => { @@ -82,17 +155,19 @@ test.describe('Storage S3 — Upload', () => { await rowByName(page, textFixture.name).dblclick(); await expect(page.getByRole('heading', { name: textFixture.name })).toBeVisible(); await expect(page.getByText(textFixture.expectedSnippet)).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).last().click(); + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); + await expect(page.getByRole('heading', { name: textFixture.name })).not.toBeVisible(); await rowByName(page, csvFixture.name).dblclick(); await expect(page.getByRole('heading', { name: csvFixture.name })).toBeVisible(); await expect(page.getByText(csvFixture.expectedCell)).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).last().click(); + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); + await expect(page.getByRole('heading', { name: csvFixture.name })).not.toBeVisible(); await rowByName(page, imageFixture.name).dblclick(); await expect(page.getByRole('heading', { name: imageFixture.name })).toBeVisible(); await expect(page.getByAltText(`Preview of ${imageFixture.name}`)).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).last().click(); + await page.keyboard.press('Escape'); } finally { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } @@ -196,4 +271,112 @@ test.describe('Storage S3 — Upload', () => { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } }); + + test('uploads nothing when the conflict check fails', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'upload-check-fails'); + const key = `${prefix}existing.txt`; + const failHead = (route: Route) => + route.request().method() === 'HEAD' ? route.fulfill({ status: 500 }) : route.fallback(); + + try { + await putTextObject(client, credentials.bucket, key, 'old content'); + await connectAndOpenPrefix(page, credentials, prefix); + + await page.route('**/api/storage/download?*', failHead); + await page.getByRole('button', { name: 'Upload' }).click(); + const uploadModal = modalBox(page); + await uploadModal.locator('input[aria-label="Select files"]').setInputFiles({ + name: 'existing.txt', + mimeType: 'text/plain', + buffer: Buffer.from('new content') + }); + await uploadModal.getByRole('button', { name: 'Upload', exact: true }).click(); + + await expect(uploadModal.getByRole('alert')).toContainText('Nothing was uploaded'); + await expect(await getObjectText(client, credentials.bucket, key)).toBe('old content'); + + // Once the check works again, the retry finds the conflict instead of overwriting. + await page.unroute('**/api/storage/download?*', failHead); + await uploadModal.getByRole('button', { name: 'Upload', exact: true }).click(); + await expect(uploadModal.getByText('Files already exist')).toBeVisible(); + await expect(uploadModal.getByRole('alert')).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [key]); + } + }); + + test('asks before leaving during an upload, and leaving stops it', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'upload-leave'); + const keys = [`${prefix}inner/kept.txt`, `${prefix}inner/stopped.txt`, `${prefix}inner/`]; + const uploads: Route[] = []; + + try { + await putDirectoryMarker(client, credentials.bucket, `${prefix}inner/`); + await connectAndOpenPrefix(page, credentials, prefix); + const parentUrl = page.url(); + // Open the folder in the app, so that Back is an in-app navigation. + await rowByName(page, 'inner').click(); + await expect(page).not.toHaveURL(parentUrl); + await waitForObjectsLoaded(page); + const browseUrl = page.url(); + // Hold the upload request open so that it is still running when leaving. + await page.route('**/api/storage/upload*', (route) => { + uploads.push(route); + }); + + const startUpload = async (name: string) => { + await page.getByRole('button', { name: 'Upload', exact: true }).click(); + const uploadModal = modalBox(page); + await uploadModal.locator('input[aria-label="Select files"]').setInputFiles({ + name, + mimeType: 'text/plain', + buffer: Buffer.from('slow upload') + }); + const count = uploads.length; + await uploadModal.getByRole('button', { name: 'Upload' }).click(); + await expect.poll(() => uploads.length).toBe(count + 1); + }; + await startUpload('kept.txt'); + + // Back is held up by a warning; staying keeps the upload. + const warning = page.getByRole('dialog', { name: 'Upload in progress' }); + await page.goBack(); + await expect(warning).toBeVisible(); + expect(page.url()).toBe(browseUrl); + await warning.getByRole('button', { name: 'Stay' }).click(); + await expect(warning).toBeHidden(); + await expect(page.getByRole('heading', { name: 'Upload', exact: true })).toBeVisible(); + + // Closing the tab gets the browser's own confirmation. + const unload = page.waitForEvent('dialog'); + await page.close({ runBeforeUnload: true }); + const prompt = await unload; + expect(prompt.type()).toBe('beforeunload'); + await prompt.dismiss(); + + // After staying, the finished upload shows up in the folder. + await uploads[0].continue(); + await expect(modalBox(page).getByRole('status')).toContainText('Upload complete'); + await modalBox(page).getByRole('button', { name: 'Done' }).click(); + await expect(rowByName(page, 'kept.txt')).toBeVisible(); + + // Leaving stops the upload and goes where Back pointed. + await startUpload('stopped.txt'); + await page.goBack(); + const aborted = page.waitForEvent('requestfailed', (request) => + request.url().includes('/api/storage/upload') + ); + await warning.getByRole('button', { name: 'Stop upload and leave' }).click(); + await aborted; + await expect(page).toHaveURL(parentUrl); + await expect(page.getByRole('heading', { name: 'Upload', exact: true })).toBeHidden(); + } finally { + await page.unrouteAll({ behavior: 'ignoreErrors' }); + await deleteKnownKeys(client, credentials.bucket, keys); + } + }); }); diff --git a/e2e/support/containers/garage.setup.ts b/e2e/support/containers/garage.setup.ts new file mode 100644 index 00000000..8b206ba0 --- /dev/null +++ b/e2e/support/containers/garage.setup.ts @@ -0,0 +1,83 @@ +import path from 'path'; +import { GenericContainer, type StartedTestContainer, Wait } from 'testcontainers'; +import { createGarageBucketCredentials, createGarageHiddenBucket } from '../garage.js'; + +export async function startGarage(): Promise { + const garageStart = Date.now(); + console.log('Starting Garage S3 testcontainer...'); + const garageContainerBuilder = new GenericContainer( + 'oci.stackable.tech/stackable/dxflrs/garage:v2.3.0' + ) + .withEntrypoint(['/garage']) + .withCommand(['server', '--single-node']) + .withEnvironment({ GARAGE_CONFIG_FILE: '/etc/garage/garage.toml' }) + .withBindMounts([ + { + source: path.resolve('dev/garage/garage.toml'), + target: '/etc/garage/garage.toml', + mode: 'ro' + } + ]) + .withExposedPorts(3900, 3902) + .withStartupTimeout(120_000) + .withWaitStrategy(Wait.forHttp('/health', 3902).forStatusCode(200)); + if (process.env.DOCKER_NETWORK) { + garageContainerBuilder.withNetworkMode(process.env.DOCKER_NETWORK); + } + const garageContainer = await garageContainerBuilder.start(); + console.log(`Garage S3 started in ${Date.now() - garageStart}ms`); + + await setupGarageBucket(garageContainer); + + return garageContainer; +} + +async function setupGarageBucket(garageContainer: StartedTestContainer): Promise { + const garageHost = garageContainer.getHost(); + const garageS3Endpoint = `http://${garageHost}:${garageContainer.getMappedPort(3900)}`; + const garageAdminUrl = `http://${garageHost}:${garageContainer.getMappedPort(3902)}`; + // Admin token is defined in dev/garage/garage.toml. + const garageAdminToken = 'stackable-cockpit-e2e-admin-token'; + + process.env.GARAGE_ADMIN_URL = garageAdminUrl; + process.env.GARAGE_ADMIN_TOKEN = garageAdminToken; + + // Create the test bucket and access key via the Garage admin API. + const bucketStart = Date.now(); + console.log('Creating Garage test bucket and credentials...'); + const credentials = await createGarageBucketCredentials( + { + endpoint: garageS3Endpoint, + region: 'garage', + accessKeyId: '', + secretAccessKey: '', + bucket: 'test-bucket' + }, + { + bucketName: 'test-bucket', + keyName: 'e2e-test-app', + permissions: { owner: true, read: true, write: true } + } + ); + console.log(`Garage setup complete in ${Date.now() - bucketStart}ms`); + + process.env.S3_TEST_ENDPOINT = garageS3Endpoint; + process.env.S3_TEST_REGION = 'garage'; + process.env.S3_TEST_ACCESS_KEY_ID = credentials.accessKeyId; + process.env.S3_TEST_SECRET_ACCESS_KEY = credentials.secretAccessKey; + process.env.S3_TEST_BUCKET = 'test-bucket'; + + // Hidden bucket: no global alias so it does not appear in S3 ListBuckets, + // but the test key has read and write access. + const hiddenBucketId = await createGarageHiddenBucket( + { + endpoint: garageS3Endpoint, + region: 'garage', + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + bucket: 'test-bucket' + }, + credentials.accessKeyId + ); + process.env.S3_TEST_HIDDEN_BUCKET_ID = hiddenBucketId; +} diff --git a/e2e/support/containers/postgres.setup.ts b/e2e/support/containers/postgres.setup.ts new file mode 100644 index 00000000..1cc781f0 --- /dev/null +++ b/e2e/support/containers/postgres.setup.ts @@ -0,0 +1,22 @@ +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; + +export async function startPostgres(): Promise { + const pgStart = Date.now(); + console.log('Starting PostgreSQL testcontainer...'); + const pgContainerBuilder = new PostgreSqlContainer( + 'oci.stackable.tech/stackable/library/postgres:18.4-alpine3.24' + ).withStartupTimeout(120_000); + if (process.env.DOCKER_NETWORK) { + pgContainerBuilder.withNetworkMode(process.env.DOCKER_NETWORK); + } + const pgContainer = await pgContainerBuilder.start(); + console.log(`PostgreSQL started in ${Date.now() - pgStart}ms`); + + process.env.DATABASE_HOST = pgContainer.getHost(); + process.env.DATABASE_PORT = pgContainer.getPort().toString(); + process.env.DATABASE_NAME = pgContainer.getDatabase(); + process.env.DATABASE_USER = pgContainer.getUsername(); + process.env.DATABASE_PASSWORD = pgContainer.getPassword(); + + return pgContainer; +} diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts new file mode 100644 index 00000000..a1604218 --- /dev/null +++ b/e2e/support/fixtures.ts @@ -0,0 +1,50 @@ +import { test as base, expect } from '@playwright/test'; +import path from 'path'; +import { waitForHydration } from './helpers.js'; + +export * from '@playwright/test'; + +// Each worker logs in once and reuses that session for all of its tests. The +// mock OIDC server issues a different `sub` per login, so parallel workers act +// as different users and cannot race on per-user server-side state (saved and +// active storage connections, the Trino connection store, recent items). +// Playwright recommends this pattern for tests that modify server-side state: +// https://playwright.dev/docs/auth#moderate-one-account-per-parallel-worker +export const test = base.extend({ + storageState: ({ workerStorageState }, use) => use(workerStorageState), + + workerStorageState: [ + async ({ browser }, use, workerInfo) => { + const authFile = path.join( + import.meta.dirname, + `../.auth/user-${workerInfo.project.name}-${workerInfo.parallelIndex}.json` + ); + + // Always log in afresh: sessions live in the per-run database, so a file + // from an earlier run would point at a session that no longer exists. + const context = await browser.newContext({ + baseURL: workerInfo.project.use.baseURL, + storageState: { cookies: [], origins: [] } + }); + const page = await context.newPage(); + + // Navigate to the app — auth guard redirects to /auth/login + await page.goto('/'); + await expect(page).toHaveURL(/\/auth\/login/); + + // Wait for hydration so the button's onclick handler is attached + await waitForHydration(page); + + // Mock OIDC server auto-redirects — no form interaction needed + await page.getByRole('button', { name: /sign in with sso/i }).click(); + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + + await context.storageState({ path: authFile }); + await context.close(); + + await use(authFile); + }, + { scope: 'worker' } + ] +}); diff --git a/e2e/support/garage.ts b/e2e/support/garage.ts index 3d4142b0..e778a4bc 100644 --- a/e2e/support/garage.ts +++ b/e2e/support/garage.ts @@ -37,6 +37,7 @@ function readString(value: unknown, keys: string[]): string | null { } for (const key of keys) { + // eslint-disable-next-line security/detect-object-injection const candidate = record[key]; if (typeof candidate === 'string' && candidate.length > 0) { return candidate; @@ -53,6 +54,7 @@ function readStringArray(value: unknown, keys: string[]): string[] { } for (const key of keys) { + // eslint-disable-next-line security/detect-object-injection const candidate = record[key]; if (Array.isArray(candidate)) { return candidate.filter((item): item is string => typeof item === 'string'); @@ -73,6 +75,7 @@ function extractAdminList(value: unknown): JsonRecord[] { } for (const key of ADMIN_LIST_KEYS) { + // eslint-disable-next-line security/detect-object-injection const candidate = record[key]; if (Array.isArray(candidate)) { return candidate @@ -285,3 +288,46 @@ export async function createGarageBucketCredentials( bucket: options.bucketName }; } + +/** + * Creates a bucket with no global alias (so it is absent from S3 ListBuckets + * responses) and grants the given access key read and write — but not owner — + * access. Returns the Garage bucket ID, which is the only handle for the + * bucket when it has no alias. + */ +export async function createGarageHiddenBucket( + baseCredentials: GarageCredentials, + accessKeyId: string +): Promise { + const created = await adminPost('/v2/CreateBucket', {}); + const bucketId = readString(created, ['id', 'bucketId', 'bucket_id']); + + if (!bucketId) { + throw new Error('Garage hidden bucket was created without an ID'); + } + + await adminPost('/v2/AllowBucketKey', { + bucketId, + accessKeyId, + permissions: { owner: false, read: true, write: true } + }); + + return bucketId; +} + +export function hasHiddenBucketId(): boolean { + return Boolean(process.env.S3_TEST_HIDDEN_BUCKET_ID); +} + +export function requireHiddenBucketId(): string { + const id = process.env.S3_TEST_HIDDEN_BUCKET_ID; + + if (!id) { + throw new Error( + 'Hidden bucket ID is not available — S3_TEST_HIDDEN_BUCKET_ID is not set. ' + + 'Run the dev setup or ensure init-garage-s3.sh has run.' + ); + } + + return id; +} diff --git a/e2e/support/global-setup.ts b/e2e/support/global-setup.ts index 3754ac1b..e6b9fd87 100644 --- a/e2e/support/global-setup.ts +++ b/e2e/support/global-setup.ts @@ -1,35 +1,12 @@ -import fs from 'node:fs'; -import path from 'path'; +import fs from 'node:fs/promises'; +import path from 'node:path'; export default async function globalSetup() { process.loadEnvFile(path.join(import.meta.dirname, '../..', '.env.test')); - // Optionally load S3 credentials written by the Garage setup step in CI. - // When present, storage S3 tests run; when absent, they are skipped. - // The admin URL/token from s3-config.json override .env.test values so that - // the permissions tests connect to the correct Garage admin port in CI. - const s3ConfigPath = path.join(import.meta.dirname, '../..', 's3-config.json'); - if (fs.existsSync(s3ConfigPath)) { - const raw = fs.readFileSync(s3ConfigPath, 'utf-8'); - const cfg = JSON.parse(raw) as { - awsEndpoint: string; - awsRegion: string; - awsAccessKeyId: string; - awsSecretAccessKey: string; - bucket: string; - garageAdminUrl?: string; - garageAdminToken?: string; - }; - process.env.S3_TEST_ENDPOINT = cfg.awsEndpoint; - process.env.S3_TEST_REGION = cfg.awsRegion; - process.env.S3_TEST_ACCESS_KEY_ID = cfg.awsAccessKeyId; - process.env.S3_TEST_SECRET_ACCESS_KEY = cfg.awsSecretAccessKey; - process.env.S3_TEST_BUCKET = cfg.bucket; - if (cfg.garageAdminUrl) { - process.env.GARAGE_ADMIN_URL = cfg.garageAdminUrl; - } - if (cfg.garageAdminToken) { - process.env.GARAGE_ADMIN_TOKEN = cfg.garageAdminToken; - } - } + // Playwright starts webServer before globalSetup. The app launcher creates + // the containers first and publishes their environment for the test workers. + const raw = await fs.readFile(path.resolve('.playwright/test-env.json'), 'utf-8'); + const env = JSON.parse(raw) as Record; + Object.assign(process.env, env); } diff --git a/e2e/support/start-app.ts b/e2e/support/start-app.ts new file mode 100644 index 00000000..edfcdd12 --- /dev/null +++ b/e2e/support/start-app.ts @@ -0,0 +1,73 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { StartedTestContainer } from 'testcontainers'; +import { startPostgres } from './containers/postgres.setup.js'; +import { startGarage } from './containers/garage.setup.js'; + +process.loadEnvFile(path.join(import.meta.dirname, '../..', '.env.test')); + +const stateFile = path.resolve('.playwright/test-env.json'); +const containers: StartedTestContainer[] = []; +let app: ChildProcess | undefined; +let stopping = false; + +async function stop(exitCode: number) { + if (stopping) return; + stopping = true; + if (app && app.exitCode === null && app.signalCode === null) { + const exited = new Promise((resolve) => app!.once('exit', () => resolve())); + app.kill('SIGTERM'); + await exited; + } + console.log('Tearing down testcontainers...'); + await Promise.allSettled(containers.map((container) => container.stop())); + await fs.rm(stateFile, { force: true }); + process.exit(exitCode); +} + +/** Run the E2E app with the same adapter-node body size limit as the image. */ +async function productionBodySizeLimit(): Promise { + const dockerfile = await fs.readFile( + path.join(import.meta.dirname, '../../docker/Dockerfile'), + 'utf-8' + ); + const limit = /^ENV BODY_SIZE_LIMIT=(\S+)$/m.exec(dockerfile)?.[1]; + if (!limit) throw new Error('BODY_SIZE_LIMIT not found in docker/Dockerfile'); + return limit; +} + +async function main() { + // Own the containers in the webServer process so both direct Playwright runs + // and npm run test:e2e use the same startup order and automatic teardown. + containers.push(await startPostgres()); + containers.push(await startGarage()); + + const testEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + key.startsWith('DATABASE_') || key.startsWith('S3_TEST_') || key.startsWith('GARAGE_') + ) + ); + await fs.mkdir(path.dirname(stateFile), { recursive: true }); + await fs.writeFile(stateFile, JSON.stringify(testEnv), { mode: 0o600 }); + + // The server's init hook runs migrations before it starts accepting requests. + app = spawn(process.execPath, ['build/index.js'], { + stdio: 'inherit', + env: { ...process.env, BODY_SIZE_LIMIT: await productionBodySizeLimit() } + }); + app.on('error', (err) => { + console.error('Failed to start the E2E app server:', err); + void stop(1); + }); + app.on('exit', (code) => void stop(code ?? 1)); +} + +process.on('SIGTERM', () => void stop(0)); +process.on('SIGINT', () => void stop(0)); + +main().catch((err) => { + console.error('Failed to start the E2E environment:', err); + void stop(1); +}); diff --git a/e2e/trino/catalog-browser.spec.ts b/e2e/trino/catalog-browser.spec.ts index 323b2206..75ce3581 100644 --- a/e2e/trino/catalog-browser.spec.ts +++ b/e2e/trino/catalog-browser.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { waitForHydration, ensureCatalogBrowserOpen, setTabSql } from '../support/helpers'; test.describe('Catalog browser', () => { diff --git a/e2e/trino/connection-form.spec.ts b/e2e/trino/connection-form.spec.ts index 3e522b03..cb447aab 100644 --- a/e2e/trino/connection-form.spec.ts +++ b/e2e/trino/connection-form.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { stringify } from 'devalue'; import { waitForHydration, waitForQueryComplete, setTabSql } from '../support/helpers'; import { MOCK_TRINO_URL } from '../support/mock-trino'; diff --git a/e2e/trino/query-api.spec.ts b/e2e/trino/query-api.spec.ts index 5fe60b06..b9e3bf00 100644 --- a/e2e/trino/query-api.spec.ts +++ b/e2e/trino/query-api.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type APIRequestContext } from '@playwright/test'; +import { test, expect, type APIRequestContext } from '../support/fixtures.js'; import { randomUUID } from 'node:crypto'; import { mockMarker, mockTrinoStatements, releaseMockTrino } from '../support/mock-trino'; diff --git a/e2e/trino/query-lifecycle.spec.ts b/e2e/trino/query-lifecycle.spec.ts index 861b4edb..0e9b0b04 100644 --- a/e2e/trino/query-lifecycle.spec.ts +++ b/e2e/trino/query-lifecycle.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Page } from '../support/fixtures.js'; import { waitForHydration, waitForQueryComplete, setTabSql } from '../support/helpers'; import { mockMarker, mockTrinoStatements, releaseMockTrino } from '../support/mock-trino'; diff --git a/e2e/trino/trino-tabs.spec.ts b/e2e/trino/trino-tabs.spec.ts index 1dd01b26..987c6fb8 100644 --- a/e2e/trino/trino-tabs.spec.ts +++ b/e2e/trino/trino-tabs.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { waitForHydration, waitForQueryComplete, setTabState } from '../support/helpers'; test.describe('Trino editor tabs', () => { @@ -59,6 +59,30 @@ test.describe('Trino editor tabs', () => { await expect(tabs).toHaveCount(1); }); + test('close non-active tab removes it and preserves active tab', async ({ page }) => { + await setTabState(page, [{ sql: 'SELECT 1' }, { sql: 'SELECT 2' }]); + await page.goto('/trino'); + await waitForHydration(page); + + const tabs = page.locator('[role="tab"]'); + await expect(tabs).toHaveCount(2); + + // Switch to the second tab to make it active + await tabs.nth(1).click(); + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true'); + + // Close the first (non-active) tab + const closeBtn = tabs + .nth(0) + .locator('..') + .getByRole('button', { name: /Close tab/ }); + await closeBtn.click(); + + // Only one tab remains with default label — it should still be active + await expect(page.locator('[role="tab"]')).toHaveCount(1); + await expect(page.locator('[role="tab"]').nth(0)).toHaveAttribute('aria-selected', 'true'); + }); + test('cannot close last remaining tab', async ({ page }) => { // With only one tab, close button should not be visible. const tabs = page.locator('[role="tab"]'); @@ -112,6 +136,8 @@ test.describe('Trino editor tabs', () => { await setTabState(page, [{ sql: 'SELECT id, name FROM users' }, { sql: 'SELECT 2' }]); await page.goto('/trino'); await waitForHydration(page); + // Wait for Monaco editor to be fully initialised. + await page.locator('[data-ready]').first().waitFor({ timeout: 15_000 }); // Focus editor and run query on first tab. await page.locator('.monaco-editor').first().click(); diff --git a/e2e/trino/trino.spec.ts b/e2e/trino/trino.spec.ts index 514794d1..b305cef4 100644 --- a/e2e/trino/trino.spec.ts +++ b/e2e/trino/trino.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../support/fixtures.js'; import { waitForHydration, waitForQueryComplete, setTabSql } from '../support/helpers'; test.describe('Trino query editor', () => { diff --git a/eslint.config.js b/eslint.config.js index 10b01428..e9db4541 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,8 @@ import { defineConfig } from 'eslint/config'; import svelte from 'eslint-plugin-svelte'; import betterTailwindcss from 'eslint-plugin-better-tailwindcss'; import security from 'eslint-plugin-security'; +import importPlugin from 'eslint-plugin-import'; +import checkFile from 'eslint-plugin-check-file'; import globals from 'globals'; import { fileURLToPath } from 'node:url'; import ts from 'typescript-eslint'; @@ -13,7 +15,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( includeIgnoreFile(gitignorePath), - { ignores: ['src/lib/editor/generated/**'] }, + { ignores: ['src/lib/editor/generated/**', 'src/lib/server/migrate.ts', 'e2e/support/**'] }, js.configs.recommended, ...ts.configs.recommended, ...svelte.configs.recommended, @@ -26,10 +28,15 @@ export default defineConfig( }, rules: { 'no-undef': 'off', - // It will currently also error on external links or links with query parameters + 'no-console': 'off', + // It will currently also error on external links or links with query parameters. // https://github.com/sveltejs/eslint-plugin-svelte/issues/1353 'svelte/no-navigation-without-resolve': 'warn', - // Too common occurrence in this project, disabling for now + 'svelte/no-restricted-html-elements': [ + 'error', + { elements: ['dialog'], message: 'Use the shared Modal component instead of .' } + ], + // Too common occurrence in this project, disabling for now. 'security/detect-object-injection': 'off' } }, @@ -56,9 +63,130 @@ export default defineConfig( 'warn', { detectComponentClasses: true, - // DaisyUIs nestes selectors are not detected by this rule. - // Ignore them to catch outdated classes from DaisyUI v4 Agents seem to love. - ignore: ['dropdown-content', 'tab-active', 'swap-on', 'swap-off', 'menu-disabled'] + ignore: [ + 'dropdown-content', + 'dropdown-open', + 'swap-on', + 'swap-off', + 'label-text', + 'menu-disabled', + 'tab-active', + 'tab-strip', + 'preview-scroll', + 'modal-top-search' + ] + } + ], + 'better-tailwindcss/no-restricted-classes': [ + 'error', + { + restrict: [ + '^(bg-white|bg-black|text-(gray|slate|zinc)-\\d+|bg-(gray|slate|zinc)-\\d+|border-(gray|slate)-\\d+)$' + ] + } + ] + } + }, + { + files: ['src/lib/server/**/*.ts', 'src/routes/**/*.server.ts'], + rules: { 'no-console': 'error' } + }, + { + files: ['src/**/*.ts'], + ignores: ['src/**/*.test.ts', 'src/**/*.spec.ts'], + rules: { 'max-lines': ['error', { max: 2400, skipBlankLines: false, skipComments: false }] } + }, + { + files: ['src/**/*.svelte'], + rules: { 'max-lines': ['error', { max: 1200, skipBlankLines: false, skipComments: false }] } + }, + { + files: ['src/**/*.{test,spec}.ts'], + rules: { 'max-lines': ['error', { max: 1000, skipBlankLines: false, skipComments: false }] } + }, + { + files: [ + 'src/lib/stores/**/*.{ts,svelte.ts}', + 'src/lib/components/**/*.svelte', + 'src/lib/server/**/*.svelte.ts' + ], + plugins: { 'check-file': checkFile }, + rules: { + 'check-file/filename-naming-convention': [ + 'error', + { + 'src/lib/components/**/!(*.spec).svelte': 'PASCAL_CASE' + } + ], + 'check-file/filename-blocklist': [ + 'error', + { + 'src/lib/stores/**/!(*.svelte|*.spec).ts': '*.svelte.ts', + 'src/lib/server/**/*.svelte.ts': '*.ts' + } + ] + } + }, + { + files: ['src/lib/components/Modal.svelte'], + rules: { 'svelte/no-restricted-html-elements': 'off' } + }, + { + files: [ + 'src/lib/components/layout/sidebar/Sidebar.svelte', + 'src/lib/components/storage/landing/RecentItems.svelte', + 'src/routes/**/edit/+page.svelte', + 'src/routes/(app)/storage/+layout.svelte' + ], + rules: { 'svelte/no-navigation-without-resolve': 'off' } + }, + { + files: [ + 'src/lib/client/**/*.ts', + 'src/lib/stores/**/*.ts', + 'src/lib/storage/**/*.ts', + 'src/lib/editor/**/*.ts', + 'src/lib/types/**/*.ts' + ], + plugins: { import: importPlugin }, + settings: { 'import/resolver': { typescript: true } }, + rules: { + 'import/no-restricted-paths': [ + 'error', + { + zones: [ + { + target: './src/lib', + from: './src/lib/server', + message: 'Client-side code must not import server code.' + } + ] + } + ] + } + }, + { + files: ['src/lib/server/**/*.ts'], + plugins: { import: importPlugin }, + settings: { 'import/resolver': { typescript: true } }, + rules: { + 'import/no-restricted-paths': [ + 'error', + { + zones: [ + { + target: './src/lib/server', + from: './src/lib/client', + // The server re-exports the PUBLIC_ flags it enforces, so each is parsed once. + except: ['./feature-flags.ts'], + message: 'Server code must not import client-only utilities.' + }, + { + target: './src/lib/server', + from: './src/lib/stores', + message: 'Server code must not import Svelte stores.' + } + ] } ] } diff --git a/messages/de.json b/messages/de.json index 9859f874..6452b783 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1,5 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "action_close": "Schließen", + "action_dismiss": "Schließen", "dashboard_welcome": "Willkommen zurück", "dashboard_subtitle": "Stackable Unified Data Platform – Übersicht", "dashboard_services": "Dienste", @@ -21,6 +23,7 @@ "nav_badge_soon": "Bald", "sidebar_label": "Seitenleiste", "sidebar_nav_label": "Hauptnavigation", + "navigation_loading": "Wird geladen…", "sidebar_close_nav": "Navigation schließen", "sidebar_expand": "Seitenleiste ausklappen", "sidebar_collapse": "Seitenleiste einklappen", @@ -154,7 +157,14 @@ "storage_connect_secret_key": "Geheimer Schlüssel", "storage_connect_submit": "Verbinden", "storage_connect_reconnecting": "Verbindung zum Speicher wird wiederhergestellt…", + "storage_connect_timeout": "Zeitüberschreitung der Verbindung. Das Speicher-Backend hat nicht geantwortet.", + "storage_connect_cancel": "Abbrechen", "storage_connect_error": "Verbindung fehlgeschlagen — Endpunkt und Zugangsdaten prüfen.", + "storage_connect_error_unreachable": "Verbindung zum ausgewählten Speicher nicht möglich — der Endpunkt ist möglicherweise nicht erreichbar.", + "storage_connect_error_access_denied": "Zugriff verweigert — Zugangsdaten prüfen.", + "storage_connect_error_not_found": "Speicher-Backend nicht gefunden — Endpunkt prüfen.", + "storage_connect_error_server_error": "Das Speicher-Backend hat einen Fehler gemeldet — Endpunkt und Zugangsdaten prüfen.", + "storage_connect_error_network": "Speicher-Backend nicht erreichbar — Host und Port prüfen.", "storage_connect_error_hdfs": "HDFS-Verbindungen werden noch nicht unterstützt.", "storage_connect_saved": "Gespeicherte Verbindungen", "storage_connect_no_saved": "Noch keine gespeicherten Verbindungen", @@ -162,6 +172,18 @@ "storage_connect_forget_confirm": "{endpoint} aus den gespeicherten Verbindungen löschen?", "storage_connect_forget_cancel": "Abbrechen", "storage_connect_forget_label": "Verbindung zu {endpoint} löschen", + "storage_connect_name": "Verbindungsname", + "storage_connect_name_placeholder": "z.B. Primäres S3", + "storage_connect_additional_buckets": "Weitere Buckets", + "storage_connect_additional_buckets_hint": "Bucket-Namen, die nicht von ListBuckets zurückgegeben werden, einer pro Zeile", + "storage_connect_duplicate_warning": "Doppelte Zugangsdaten erkannt", + "storage_connect_delete_label": "Verbindung {name} löschen", + "storage_connect_delete_confirm_title": "\"{name}\" löschen?", + "storage_connect_delete_confirm_message": "Die gespeicherten Zugangsdaten werden dauerhaft entfernt.", + "storage_connect_delete_confirm_button": "Löschen", + "storage_connect_delete_cancel": "Abbrechen", + "storage_connect_add_new": "Neue Verbindung hinzufügen", + "storage_connect_testing": "Verbindung wird getestet…", "storage_connect_copy": "Kopieren", "storage_connect_copied": "Kopiert", "storage_connect_copy_all": "Alles kopieren", @@ -177,16 +199,17 @@ "storage_connections_empty": "Noch keine gespeicherten Verbindungen.", "storage_connections_edit": "Bearbeiten", "storage_connections_delete": "Löschen", - "storage_connections_delete_confirm": "\u201e{label}\u201c aus den gespeicherten Verbindungen entfernen? Dies kann nicht rückgängig gemacht werden.", + "storage_connections_delete_confirm": "\"{label}\" aus den gespeicherten Verbindungen entfernen? Dies kann nicht rückgängig gemacht werden.", "storage_connections_delete_cancel": "Abbrechen", "storage_connections_delete_confirm_button": "Löschen", "storage_connection_edit_title": "Verbindung bearbeiten", "storage_connection_edit_name": "Verbindungsname", "storage_connection_edit_name_placeholder": "z. B. Produktions-Garage", "storage_connection_edit_name_hint": "Optional. Wird auf der Verbindungskachel anstelle des Hostnamens angezeigt.", + "storage_connection_edit_name_taken": "Sie haben bereits eine Verbindung mit diesem Namen.", + "storage_connection_edit_secret_required": "Geben Sie den geheimen Schlüssel zum neuen Zugriffsschlüssel ein.", "storage_connection_edit_save": "Änderungen speichern", "storage_connection_edit_credentials_hint": "Leer lassen, um die aktuellen Anmeldedaten beizubehalten.", - "storage_connection_edit_secret_key_required": "Geben Sie den geheimen Schlüssel für den neuen Zugriffsschlüssel ein.", "storage_connection_edit_success": "Verbindung erfolgreich aktualisiert.", "storage_connection_edit_active_notice": "Sie sind derzeit mit dieser Verbindung verbunden.", "storage_connection_edit_not_found": "Verbindung nicht gefunden.", @@ -204,7 +227,18 @@ "storage_buckets_subtitle": "Durchsuchen und verwalten Sie Ihre gespeicherten Objekte.", "storage_view_all_buckets": "Alle Buckets anzeigen", "storage_buckets_empty": "Keine Buckets gefunden", + "storage_add_bucket": "Bucket hinzufügen", + "storage_add_bucket_title": "Mit einem Bucket verbinden", + "storage_add_bucket_subtitle": "Geben Sie den Namen eines Buckets ein, auf den Sie Zugriff haben.", + "storage_add_bucket_name_label": "Bucket-Name", + "storage_add_bucket_name_placeholder": "mein-bucket", + "storage_add_bucket_submit": "Verbinden", + "storage_add_bucket_cancel": "Abbrechen", + "storage_add_bucket_error_access_denied": "Zugriff verweigert — Sie haben keine Berechtigung, diesen Bucket zu lesen.", + "storage_add_bucket_error_not_found": "Bucket nicht gefunden — überprüfen Sie den Namen und versuchen Sie es erneut.", + "storage_add_bucket_error_unknown": "Verbindung zum Bucket fehlgeschlagen — überprüfen Sie den Namen und versuchen Sie es erneut.", "storage_bucket_empty": "Dieser Bucket ist leer", + "storage_folder_empty": "Dieser Ordner ist leer", "storage_folder": "Ordner", "storage_folders": "Ordner", "storage_file": "Datei", @@ -230,10 +264,14 @@ } ], "storage_select_toggle": "Auswahlmodus umschalten", + "storage_select_item": "{name} auswählen", + "storage_select_all": "Alle auswählen", "storage_paste": "{count} Element einfügen", "storage_paste_plural": "{count} Elemente einfügen", "storage_selected": "{count} ausgewählt", + "storage_selected_files_size": "Ausgewählte Dateien: {size}", "storage_action_preview": "Vorschau", + "storage_action_actions_for": "Aktionen für {name}", "storage_action_refresh": "Aktualisieren", "storage_action_preview_no_selection": "Keine Datei für die Vorschau ausgewählt.", "storage_action_download_no_selection": "Keine Datei für den Download ausgewählt.", @@ -245,10 +283,14 @@ "storage_action_delete": "Löschen", "storage_action_paste": "Einfügen", "storage_action_pin": "Ort anheften", + "storage_action_details": "Details", "storage_action_unpin": "Loslösen", "storage_action_copy_filename": "Dateiname kopieren", "storage_action_copy_filename_success": "Dateiname in die Zwischenablage kopiert.", "storage_action_copy_filename_error": "Dateiname konnte nicht in die Zwischenablage kopiert werden.", + "storage_action_copy_directory_name": "Verzeichnisnamen kopieren", + "storage_action_copy_directory_name_success": "Verzeichnisname in die Zwischenablage kopiert.", + "storage_action_copy_directory_name_error": "Verzeichnisname konnte nicht in die Zwischenablage kopiert werden.", "storage_action_copy_path": "Pfad kopieren", "storage_action_copy_path_success": "Pfad in die Zwischenablage kopiert.", "storage_action_copy_path_error": "Pfad konnte nicht in die Zwischenablage kopiert werden.", @@ -271,10 +313,13 @@ "storage_parent_dir": "Übergeordnetes Verzeichnis", "storage_loading": "Wird geladen…", "storage_breadcrumb_more": "Weitere Ordner", + "storage_breadcrumb_label": "Brotkrumen-Navigation", + "storage_error_breadcrumb": "Brotkrumen-Navigation", "storage_error_title": "Speicherfehler", - "storage_error_access_denied": "Sie haben keine Berechtigung, auf den Bucket \u201e{bucket}\u201c zuzugreifen.", - "storage_error_not_found": "Der Bucket \u201e{bucket}\u201c existiert nicht.", - "storage_error_storage_error": "Beim Zugriff auf den Bucket \u201e{bucket}\u201c ist ein Fehler aufgetreten.", + "storage_error_access_denied": "Sie haben keine Berechtigung, auf den Bucket „{bucket}“ zuzugreifen.", + "storage_error_not_connected": "Sie sind nicht mit dem Speicher „{connection}“ verbunden. Verbinden Sie sich damit, um diesen Link zu öffnen.", + "storage_error_not_found": "Der Bucket „{bucket}“ existiert nicht.", + "storage_error_storage_error": "Beim Zugriff auf den Bucket „{bucket}“ ist ein Fehler aufgetreten.", "storage_error_back_to_storage": "Zurück zum Speicher", "storage_error_go_back": "Zurück", "storage_download_error_title": "Download fehlgeschlagen", @@ -284,7 +329,18 @@ "storage_download_error_server_error": "Beim Herunterladen der Datei ist ein Serverfehler aufgetreten. Bitte versuchen Sie es erneut.", "storage_download_error_unknown": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", "storage_download_dismiss": "Schließen", - "storage_delete_confirm_title_one": "\u201e{name}\u201c löschen?", + "storage_download_history_title": "Download-Verlauf", + "storage_download_history_empty": "Keine Downloads in den letzten 30 Tagen.", + "storage_download_history_file_count": "{count} Dateien", + "storage_download_history_zip": "ZIP", + "storage_download_history_direct": "Einzelne Dateien", + "storage_download_history_show_details": "Details anzeigen", + "storage_download_history_hide_details": "Details ausblenden", + "storage_download_history_file_not_found": "Fehlgeschlagen: Datei \"{path}\" wurde nicht gefunden", + "storage_download_history_selected_count": "{count} ausgewählt", + "storage_download_history_download_selected": "Ausgewählte herunterladen", + "storage_download_history_download_all": "Alle herunterladen", + "storage_delete_confirm_title_one": "„{name}“ löschen?", "storage_delete_confirm_title_many": "{count} Elemente löschen?", "storage_delete_confirm_message": "Diese Aktion kann nicht rückgängig gemacht werden.", "storage_delete_dir_warning_heading": "Alle Inhalte werden dauerhaft gelöscht", @@ -295,6 +351,8 @@ "storage_delete_error_access_denied": "Zugriff verweigert. Sie haben keine Berechtigung, dieses Element zu löschen.", "storage_delete_error_server_error": "Beim Löschen ist ein Serverfehler aufgetreten. Bitte versuchen Sie es erneut.", "storage_delete_error_unknown": "Beim Löschen ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "storage_delete_success_one": "\"{name}\" gelöscht.", + "storage_delete_success_many": "{count} Elemente gelöscht.", "storage_delete_partial_failure": [ { "declarations": ["input count", "local countPlural = count: plural"], @@ -324,64 +382,423 @@ "storage_preview_unsupported_desc": "Dieser Dateityp kann nicht inline angezeigt werden.", "storage_preview_binary_title": "Binäre Inhalte", "storage_preview_binary_desc": "Diese Datei scheint binäre Daten zu enthalten und kann nicht als Text angezeigt werden.", + "storage_preview_7zip_unsupported_desc": "7zip wird noch nicht unterstützt.", + "storage_preview_rar_unsupported_desc": "RAR-Archive werden nicht unterstützt.", "storage_preview_error_title": "Vorschau fehlgeschlagen", "storage_preview_error_desc": "Beim Laden der Vorschau ist ein Fehler aufgetreten.", "storage_preview_access_denied": "Sie haben keine Berechtigung, diese Datei in der Vorschau anzuzeigen.", "storage_preview_not_found": "Die Datei konnte nicht gefunden werden.", "storage_preview_image_alt": "Vorschau von {name}", "storage_preview_csv_columns": "{count} weitere Spalten", + "storage_preview_csv_label": "CSV-Vorschau", + "storage_preview_parquet_label": "Parquet-Vorschau", + "storage_preview_text_label": "Dateiinhalt-Vorschau", "storage_preview_csv_rows": "Erste {count} Zeilen werden angezeigt", + "storage_preview_csv_rows_complete": "Alle {count} Zeilen werden angezeigt", "storage_preview_parquet_rows": "Erste {count} von {total} Zeilen werden angezeigt (Parquet)", + "storage_preview_infinite_scroll_disabled": "Dies ist das Ende der Vorschau. Laden Sie die vollständige Datei herunter oder bitten Sie den Administrator, das unendliche Scrollen zu aktivieren, um weitere Zeilen in dieser Vorschau zu laden.", "storage_preview_pdf_too_large": "PDF ist zu groß für die Inline-Vorschau.", "storage_preview_image_too_large": "Bild ist zu groß für die Anzeige.", "storage_preview_image_too_large_desc": "Die Datei überschreitet die maximale Vorschaugröße. Laden Sie die vollständige Datei herunter, um sie anzuzeigen.", "storage_preview_maximise": "Maximieren", "storage_preview_restore": "Wiederherstellen", + "storage_editor_save": "Speichern", + "storage_editor_save_action": "Datei speichern", + "storage_editor_label": "Text-Editor", + "storage_editor_saving": "Speichern…", + "storage_editor_saved": "Gespeichert", + "storage_editor_unsaved_title": "Ungespeicherte Änderungen", + "storage_editor_unsaved_desc": "Sie haben ungespeicherte Änderungen. Was möchten Sie tun?", + "storage_editor_save_and_close": "Speichern und schließen", + "storage_editor_discard": "Verwerfen", + "storage_editor_cancel": "Abbrechen", + "storage_editor_error": "Datei konnte nicht gespeichert werden.", + "storage_editor_too_large": "Datei zu groß zum Speichern. Die maximal bearbeitbare Dateigröße beträgt {limit}.", + "storage_editor_too_large_badge": "Schreibgeschützt: überschreitet {limit}", + "storage_editor_readonly_truncated": "Schreibgeschützt: Nur der Anfang dieser Datei wurde geladen", + "storage_editor_readonly_encoding": "Schreibgeschützt: Diese Datei ist kein UTF-8-Text und kann nicht gespeichert werden, ohne ihre Kodierung zu ändern", + "storage_editor_readonly_archive": "Schreibgeschützt: Dateien in Archiven können nicht bearbeitet werden", + "storage_editor_readonly_no_etag": "Schreibgeschützt: Die Version dieser Datei konnte nicht ermittelt werden, Änderungen könnten daher die Bearbeitungen anderer überschreiben", + "storage_editor_conflict": "Nicht gespeichert: Die Datei wurde geändert oder gelöscht, nachdem Sie sie geöffnet haben. Kopieren Sie Ihre Änderungen und öffnen Sie die Datei erneut.", "storage_context_menu_actions": "Aktionen", "storage_action_upload": "Hochladen", "storage_upload_title": "Hochladen", "storage_upload_drop_prompt": "Dateien oder Ordner hier ablegen oder klicken zum Durchsuchen", - "storage_upload_select_files": "Dateien ausw\u00e4hlen", - "storage_upload_select_folder": "Ordner ausw\u00e4hlen", - "storage_upload_selected": "Ausgew\u00e4hlt: {name}", + "storage_upload_browser_drop_prompt": "Dateien oder Ordner ablegen, um sie in den aktuellen Ordner hochzuladen", + "storage_upload_select_files": "Dateien auswählen", + "storage_upload_select_folder": "Ordner auswählen", + "storage_upload_selected": "Ausgewählt: {name}", "storage_upload_to_prefix": "Hochladen nach: {prefix}", "storage_upload_total_size": "Gesamt: {size}", "storage_upload_start": "Hochladen", - "storage_upload_checking": "Auf Konflikte pr\u00fcfen\u2026", + "storage_upload_checking": "Auf Konflikte prüfen…", + "storage_upload_check_error": "Es konnte nicht geprüft werden, ob die Dateien bereits vorhanden sind. Es wurde nichts hochgeladen. Bitte versuchen Sie es erneut.", "storage_upload_conflicts_title": "Dateien bereits vorhanden", "storage_upload_conflicts_desc": "Wählen Sie für jede Konfliktdatei eine Aktion.", "storage_upload_skip_all": "Alle überspringen", "storage_upload_replace_all": "Alle ersetzen", "storage_upload_resolution_replace": "Ersetzen", - "storage_upload_resolution_skip": "\u00dcberspringen", + "storage_upload_resolution_skip": "Überspringen", "storage_upload_resolution_rename": "Umbenennen", "storage_upload_rename_label": "Neuer Dateiname", - "storage_upload_rename_same_name_error": "Bitte geben Sie einen anderen Namen ein oder w\u00e4hlen Sie Ersetzen bzw. \u00dcberspringen.", - "storage_upload_rename_confirm_action": "Namen best\u00e4tigen", - "storage_upload_rename_taken": "Dieser Name existiert bereits an diesem Speicherort. Bitte w\u00e4hlen Sie einen anderen Namen.", - "storage_upload_uploading": "Wird hochgeladen\u2026 {pct}%", + "storage_upload_rename_same_name_error": "Bitte geben Sie einen anderen Namen ein oder wählen Sie Ersetzen bzw. Überspringen.", + "storage_upload_rename_confirm_action": "Namen bestätigen", + "storage_upload_rename_taken": "Dieser Name existiert bereits an diesem Speicherort. Bitte wählen Sie einen anderen Namen.", + "storage_upload_uploading": "Wird hochgeladen… {pct}%", "storage_upload_status_queued": "ausstehend", "storage_upload_status_done": "fertig", - "storage_upload_status_skipped": "\u00fcbersprungen", + "storage_upload_status_skipped": "übersprungen", "storage_upload_status_failed": "fehlgeschlagen", "storage_upload_complete_title": "Upload abgeschlossen", - "storage_upload_complete_summary": "{uploaded} hochgeladen \u00b7 {skipped} \u00fcbersprungen \u00b7 {failed} fehlgeschlagen", + "storage_upload_complete_summary": "{uploaded} hochgeladen · {skipped} übersprungen · {failed} fehlgeschlagen", "storage_upload_done": "Fertig", "storage_upload_success": "Datei erfolgreich hochgeladen.", "storage_upload_error_not_connected": "Keine Speicherverbindung konfiguriert. Bitte verbinden Sie sich zuerst.", "storage_upload_error_access_denied": "Zugriff verweigert. Sie haben keine Berechtigung, hier hochzuladen.", "storage_upload_error_no_such_bucket": "Bucket nicht gefunden.", - "storage_upload_error_invalid_part": "Upload fehlgeschlagen (Datenintegrit\u00e4tsfehler). Bitte erneut versuchen.", + "storage_upload_error_invalid_part": "Upload fehlgeschlagen (Datenintegritätsfehler). Bitte erneut versuchen.", "storage_upload_error_server_error": "Serverfehler aufgetreten. Bitte erneut versuchen.", "storage_upload_error_unknown": "Unerwarteter Fehler. Bitte erneut versuchen.", "storage_upload_overwrite_title": "Datei bereits vorhanden", - "storage_upload_overwrite_message": "Ein Objekt namens \u201e{name}\u201c ist bereits vorhanden. Was m\u00f6chten Sie tun?", + "storage_upload_overwrite_message": "Ein Objekt namens „{name}“ ist bereits vorhanden. Was möchten Sie tun?", "storage_upload_overwrite_replace": "Ersetzen", "storage_upload_overwrite_cancel": "Abbrechen", "storage_upload_overwrite_rename": "Umbenennen", "storage_upload_rename_confirm": "Mit diesem Namen hochladen", "storage_upload_retry": "Erneut versuchen", - "storage_upload_close": "Schlie\u00dfen", + "storage_upload_leave_title": "Upload läuft", + "storage_upload_leave_desc": "Wenn Sie diese Seite verlassen, wird der Upload abgebrochen. Dateien, deren Upload noch nicht abgeschlossen ist, werden nicht gespeichert.", + "storage_upload_leave_stay": "Bleiben", + "storage_upload_leave_confirm": "Upload abbrechen und verlassen", + "storage_upload_close": "Schließen", + "storage_action_create": "Erstellen", + "storage_create_file": "Neue Textdatei", + "storage_create_folder": "Neues Verzeichnis", + "storage_create_name": "Name", + "storage_create_placeholder": "Namen eingeben…", + "storage_create_confirm": "Erstellen", + "storage_create_cancel": "Abbrechen", + "storage_create_error": "{name} konnte nicht erstellt werden", + "storage_name_required": "Geben Sie einen Namen ein.", + "storage_name_empty_segment": "Entfernen Sie den überzähligen \"/\". Ein Name darf nicht mit \"/\" beginnen oder enden und kein \"//\" enthalten.", + "storage_name_invalid_single": "Geben Sie einen einzelnen Namen ein: ohne \"/\", nicht \".\" oder \"..\" und ohne die Zeichen < > \\ | ? \" oder Steuerzeichen wie Tabulatoren oder Zeilenumbrüche.", + "storage_name_invalid_path": "Geben Sie einen Namen ohne \".\" oder \"..\" als Pfadteil und ohne die Zeichen < > \\ | ? \" oder Steuerzeichen wie Tabulatoren oder Zeilenumbrüche ein.", + "storage_name_too_long": "Dieser Name ist zu lang. Der vollständige Pfad darf höchstens 1024 Bytes lang sein.", + "storage_tab_new": "Neuer Tab", + "storage_tab_close": "Tab schließen", + "storage_tab_rename": "Tab umbenennen", + "storage_tab_list": "Explorer-Tabs", + "storage_tab_context_menu": "Tab-Optionen", + "storage_restore_tabs_message": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Sie haben 1 gespeicherten Tab aus Ihrer letzten Sitzung.", + "countPlural=other": "Sie haben {count} gespeicherte Tabs aus Ihrer letzten Sitzung." + } + } + ], + "storage_restore_tabs_action": "Tabs wiederherstellen", + "storage_preview_parquet_tab_aria": "Parquet-Vorschau-Registerkarten", + "storage_preview_parquet_tab_metadata": "Metadaten", + "storage_preview_parquet_tab_data": "Daten", + "storage_preview_parquet_blocked_title": "Vorschau blockiert", + "storage_preview_parquet_blocked_desc": "Diese Parquet-Datei verwendet einen Kompressionstyp, der vom Administrator deaktiviert wurde. Wenden Sie sich an Ihren Administrator, wenn Sie dies für ein Problem halten.", + "storage_preview_parquet_overview": "Übersicht", + "storage_preview_parquet_file_size": "Dateigröße", + "storage_preview_parquet_total_rows": "Zeilen gesamt", + "storage_preview_parquet_total_columns": "Spalten gesamt", + "storage_preview_parquet_version": "Version", + "storage_preview_parquet_created_by": "Erstellt von", + "storage_preview_parquet_schema": "Schema", + "storage_preview_parquet_column": "Spalte", + "storage_preview_parquet_type": "Typ", + "storage_preview_parquet_compression_indexes": "Kompression & Indexe", + "storage_preview_parquet_row_groups": "Zeilengruppen", + "storage_preview_parquet_row_group": "Gruppe", + "storage_preview_parquet_offset_index": "Offset-Index", + "storage_preview_parquet_column_index": "Spalten-Index", + "storage_preview_parquet_available": "Verfügbar", + "storage_preview_parquet_missing": "Fehlt", + "storage_preview_parquet_none": "Keine", + "storage_preview_parquet_compression": "Kompression", + "storage_preview_parquet_uncompressed": "Unkomprimiert", + "storage_preview_parquet_compressed": "Komprimiert", + "storage_preview_parquet_size": "Größe", + "storage_preview_parquet_statistics": "Statistiken", + "storage_preview_parquet_min": "Min", + "storage_preview_parquet_max": "Max", + "storage_preview_parquet_null_count": "Null-Anzahl", + "storage_preview_parquet_distinct_count": "Distinct-Anzahl", + "storage_preview_parquet_not_stored": "Nicht gespeichert", + "storage_preview_parquet_arrow_schema": "Arrow-Schema", + "storage_preview_parquet_arrow_schema_present": "Eingebettetes Arrow-Schema ({size})", + "storage_preview_parquet_uniform": "einheitlich", + "storage_archive_open_error": "Archiv konnte nicht geöffnet werden. Das Format wird möglicherweise nicht unterstützt oder das Archiv ist beschädigt.", + "storage_archive_too_large": "Archiv ist zu groß für die Vorschau.", + "storage_archive_exit": "Archiv verlassen", + "storage_details_title": "Details", + "storage_details_file_title": "Datei-Details", + "storage_details_folder_title": "Verzeichnis-Details", + "storage_details_bucket_title": "Bucket-Details", + "storage_details_close": "Schließen", + "storage_details_maximise": "Maximieren", + "storage_details_restore": "Wiederherstellen", + "storage_details_property": "Eigenschaft", + "storage_details_value": "Wert", + "storage_details_name": "Name", + "storage_details_size": "Größe", + "storage_details_last_modified": "Zuletzt geändert", + "storage_details_content_type": "Inhaltstyp", + "storage_details_etag": "ETag", + "storage_details_version_id": "Version-ID", + "storage_details_storage_class": "Speicherklasse", + "storage_details_is_delete_marker": "Löschmarkierung", + "storage_details_yes": "Ja", + "storage_details_no": "Nein", + "storage_details_calculate_size": "Verzeichnisgröße berechnen", + "storage_details_calculating": "Wird berechnet…", + "storage_details_calculating_desc": "Alle Objekte in diesem Verzeichnis werden aufgelistet…", + "storage_details_keys_found": "{count} Objekte gefunden", + "storage_details_file_count": "{count} Dateien", + "storage_details_folder_count": "{count} Ordner", + "storage_details_total_size": "Gesamtgröße", + "storage_details_tree_visualization": "Größenverteilung", + "storage_details_versioning": "Versionierung", + "storage_details_versioning_enabled": "Aktiviert", + "storage_details_versioning_suspended": "Ausgesetzt", + "storage_details_versioning_disabled": "Deaktiviert", + "storage_details_lifecycle_rules": "Lebenszyklus-Regeln", + "storage_details_lifecycle_rule_id": "Regel-ID", + "storage_details_lifecycle_status": "Status", + "storage_details_lifecycle_filter": "Filter", + "storage_details_lifecycle_expiration": "Ablauf", + "storage_details_lifecycle_noncurrent_expiration": "Ablauf nicht aktueller Versionen", + "storage_details_lifecycle_transition": "Übergang", + "storage_details_lifecycle_days": "{days} Tage", + "storage_details_lifecycle_abort_mpu": "Abbr. unvollst. Uploads", + "storage_details_lifecycle_days_after_initiation": "{days} T. nach Initiierung", + "storage_details_lifecycle_noncurrent_transition": "Übergang inakt. Versionen", + "storage_details_lifecycle_transition_to": "→ {storageClass}", + "storage_details_lifecycle_prefix_filter": "Präfix: {prefix}", + "storage_details_lifecycle_all_objects": "Alle Objekte", + "storage_details_lifecycle_no_rules": "Keine Lebenszyklus-Regeln konfiguriert", + "storage_details_tags": "Tags", + "storage_details_no_tags": "Keine Tags", + "storage_details_unavailable": "Konnte nicht geladen werden", + "storage_details_custom_metadata": "Benutzerdefinierte Metadaten", + "storage_details_error_not_connected": "Keine Speicherverbindung konfiguriert", + "storage_details_error_fetch_file": "Dateidetails konnten nicht abgerufen werden ({status})", + "storage_details_error_fetch_bucket": "Bucket-Details konnten nicht abgerufen werden ({status})", + "storage_details_error_fetch_dir_meta": "Verzeichnismetadaten konnten nicht abgerufen werden ({status})", + "storage_details_error_calc_size": "Verzeichnisgröße konnte nicht berechnet werden ({status})", + "storage_details_error_unknown": "Unbekannter Fehler", + "storage_details_error_no_body": "Kein Antworttext", + "storage_details_unnamed_rule": "(unbenannt)", + "storage_details_treemap_aria": "Treemap-Visualisierung der Verzeichnisgrößenverteilung", + "storage_details_treemap_more": "{count} weitere", + "storage_details_copy_dir_name": "Verzeichnisnamen kopieren", + "storage_details_copy_file_name": "Dateinamen kopieren", + "storage_details_copy_full_path": "Vollständigen Pfad kopieren", + "storage_details_tab_overview": "Übersicht", + "storage_details_tab_composition": "Größenverteilung", + "storage_details_tab_contents": "Inhalte", + "storage_details_tab_lifecycle": "Lebenszyklus", + "storage_details_file_path": "Pfad", + "storage_details_bucket_name": "Bucket", + "storage_details_owner": "Besitzer", + "storage_details_permissions": "Berechtigungen", + "storage_details_depth": "Tiefe", + "storage_details_marker_exists": "Ordner-Marker", + "storage_details_marker_none": "Kein Ordner-Marker-Objekt", + "storage_details_encryption": "Verschlüsselung", + "storage_details_object_lock": "Objektsperre", + "storage_details_object_lock_mode": "Sperrmodus", + "storage_details_object_lock_until": "Aufbewahren bis", + "storage_details_truncated": "Nach {count} Objekten abgebrochen. Größe, Anzahl und Aufschlüsselung umfassen nur die bisher aufgelisteten Objekte.", + "storage_details_list_omitted": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 kleinerer Eintrag nicht angezeigt", + "countPlural=other": "{count} kleinere Einträge nicht angezeigt" + } + } + ], + "storage_action_cut_success": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Element ausgeschnitten", + "countPlural=other": "{count} Elemente ausgeschnitten" + } + } + ], + "storage_action_copy_success": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Element kopiert", + "countPlural=other": "{count} Elemente kopiert" + } + } + ], + "storage_action_paste_success": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Element eingefügt", + "countPlural=other": "{count} Elemente eingefügt" + } + } + ], + "storage_action_paste_error": "Elemente konnten nicht eingefügt werden. Bitte versuchen Sie es erneut.", + "storage_action_paste_archive_error": "Einfügen innerhalb eines Archivs nicht möglich.", + "storage_action_move_success": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Element verschoben", + "countPlural=other": "{count} Elemente verschoben" + } + } + ], + "storage_action_move_error": "Elemente konnten nicht verschoben werden. Bitte versuchen Sie es erneut.", + "storage_action_move_error_not_connected": "Keine Speicherverbindung konfiguriert. Bitte verbinden Sie sich zuerst.", + "storage_action_move_error_access_denied": "Zugriff verweigert. Sie haben keine Berechtigung zum Verschieben.", + "storage_move_confirm_title": "Elemente verschieben", + "storage_move_confirm_body": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Element nach \"{destination}\" verschieben?", + "countPlural=other": "{count} Elemente nach \"{destination}\" verschieben?" + } + } + ], + "storage_move_confirm_label": "Verschieben", + "storage_move_confirm_cancel": "Abbrechen", + "storage_move_confirm_total_size": "Gesamt: {size}", + "storage_operation_move_one": "{count} Datei verschieben", + "storage_operation_move_other": "{count} Dateien verschieben", + "storage_operation_paste_one": "{count} Datei einfügen", + "storage_operation_paste_other": "{count} Dateien einfügen", + "storage_operation_rename": "Umbenennen", + "storage_operation_done": "Fertig", + "storage_operation_failed": "Fehlgeschlagen", + "storage_operation_cancelled": "Abgebrochen", + "storage_operation_interrupted": "Unterbrochen", + "storage_operation_unknown": "Status unbekannt", + "storage_operations_label": "Vorgänge", + "storage_operations_cancel": "Vorgang abbrechen", + "storage_operations_progress": "{completed} von {total} fertig", + "storage_operations_error_generic": "Ein Fehler ist aufgetreten", + "storage_operations_active": "Aktiv", + "storage_operations_history": "Verlauf", + "storage_operations_clear_history": "Verlauf löschen", + "storage_operations_interrupted_tooltip": "Dieser Vorgang wurde durch ein Neuladen der Seite unterbrochen", + "storage_operations_unknown_tooltip": "Der Server kennt diesen Vorgang nicht mehr, daher ist das Ergebnis unbekannt", + "storage_operations_speed": "Geschwindigkeit", + "storage_operations_remaining": "verbleibend", + "storage_action_rename_inline_label": "Neuer Name", + "storage_action_rename_inline_cancel": "Abbrechen", + "storage_rename_success": "In \"{name}\" umbenannt.", + "storage_rename_error": "\"{name}\" konnte nicht umbenannt werden. Bitte versuchen Sie es erneut.", + "storage_rename_error_not_connected": "Keine Speicherverbindung konfiguriert. Bitte verbinden Sie sich zuerst.", + "storage_rename_error_access_denied": "Zugriff verweigert. Sie haben keine Berechtigung zum Umbenennen.", + "storage_rename_error_not_found": "Das Element wurde nicht gefunden. Es wurde möglicherweise verschoben oder gelöscht.", + "storage_rename_error_conflict": "Eine Datei oder ein Ordner namens \"{name}\" ist an diesem Ort bereits vorhanden.", + "storage_search_open": "Suche öffnen", + "storage_search_title": "Speicher durchsuchen", + "storage_search_close": "Suche schließen", + "storage_search_query_label": "Suchbegriff", + "storage_search_query_placeholder": "Dateien und Verzeichnisse durchsuchen", + "storage_search_scope_label": "Bucket", + "storage_search_submit": "Suchen", + "storage_search_cancel": "Abbrechen", + "storage_search_recent_heading": "Letzte Suchen", + "storage_search_recent_empty": "Ihre letzten Suchen erscheinen hier.", + "storage_search_recent_clear": "Letzte Suchen löschen", + "storage_search_recent_clear_confirm_title": "Letzte Suchen löschen?", + "storage_search_recent_clear_confirm_message": "Dadurch werden alle letzten Suchen für diese Verbindung entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", + "storage_search_recent_clear_cancel": "Abbrechen", + "storage_search_recent_clear_confirm": "Löschen", + "storage_search_view_label": "Suchansicht", + "storage_search_tab_search": "Suchen", + "storage_search_tab_recent": "Letzte", + "storage_search_no_results": "Keine Ergebnisse gefunden.", + "storage_search_failure_access_denied": "Der Zugriff auf einen oder mehrere ausgewählte Buckets wurde verweigert.", + "storage_search_failure_generic": "Ein oder mehrere ausgewählte Buckets konnten nicht durchsucht werden. Bitte versuchen Sie es erneut.", + "storage_search_failure_too_many_results": "Die Suche hat mehr als 10.000 Ergebnisse gefunden; nur die ersten werden angezeigt. Grenzen Sie die Suche ein, um alle Treffer zu sehen.", + "storage_search_session_label": "Suche {number}", + "storage_search_running": "{count} aktiv", + "storage_search_completed": "{count} abgeschlossen", + "storage_search_partial_count": "{count} teilweise", + "storage_search_add_session": "Parallele Suche", + "storage_search_sessions_label": "Suchsitzungen", + "storage_search_remove_session": "{name} entfernen", + "storage_search_status_running": "Aktiv", + "storage_search_status_done": "Fertig", + "storage_search_status_partial": "Teilweise", + "storage_search_status_cancelled": "Abgebrochen", + "storage_search_status_error": "Fehler", + "storage_search_regex_placeholder": "Regulärer Ausdruck", + "storage_search_regex_badge": ".*", + "storage_search_regex_invalid": "Dieser reguläre Ausdruck ist ungültig oder verwendet nicht unterstützte Konstrukte.", + "storage_search_all_buckets": "alle Buckets", + "storage_search_scope_all": "Alle Buckets", + "storage_search_scope_filter_label": "Buckets filtern", + "storage_search_scope_filter_placeholder": "Buckets durchsuchen", + "storage_search_scope_no_results": "Keine Buckets gefunden", + "storage_search_scope_toggle": "{bucket} in die Suche einbeziehen", + "storage_search_recent_bucket_count": "{count} Buckets", + "storage_search_recent_more_buckets": "{count} weitere Buckets", + "storage_search_recent_toggle_buckets": "Buckets auswählen", + "storage_search_recent_bucket_all": "Alle Buckets", + "storage_search_recent_bucket_toggle": "{bucket} in diese Suche einbeziehen", + "storage_search_recent_use_selected": "Ausgewählte Buckets durchsuchen", + "storage_search_advanced_options": "Erweiterte Optionen", + "storage_search_regex_label": "Suche mit regulärem Ausdruck", + "storage_search_exclude_patterns": "Ausschlussmuster", + "storage_search_exclude_placeholder": "z. B. _temp", + "storage_search_remove_exclude": "{pattern} entfernen", + "storage_search_filters_label": "Filter", + "storage_search_filter_add": "Filter hinzufügen", + "storage_search_path_prefix": "Suchpfad-Präfix", + "storage_search_path_placeholder": "z. B. events/2024/", + "storage_search_max_depth": "Maximale Ordnertiefe", + "storage_search_max_depth_invalid": "Geben Sie eine ganze Zahl von 1 bis 20 ein.", + "storage_search_depth_placeholder": "unbegrenzt", + "storage_search_searching": "Suche läuft...", + "storage_search_results_elapsed": "{count} Ergebnisse · {elapsed} ms", + "storage_search_result_type": "Typ", + "storage_search_keyboard_hint": "Eingabe: suchen · Alt+N: neue Sitzung · Esc: schließen", + "storage_search_key_enter": "Eingabe", + "storage_search_key_escape": "Esc", + "storage_search_shortcut_search": "suchen", + "storage_search_shortcut_close": "schließen", + "storage_search_filter_field_label": "Filterfeld", + "storage_search_filter_date": "Datum", + "storage_search_filter_size": "Größe", + "storage_search_filter_invalid": "Geben Sie einen gültigen Filterwert ein", + "storage_search_filter_operator": "Filteroperator", + "storage_search_filter_value_size": "Größenfilterwert", + "storage_search_filter_size_placeholder": "z. B. 10 MB", + "storage_search_filter_value_date": "Datumsfilterwert", + "storage_search_filter_date_placeholder": "z. B. 2024-01-31", + "storage_search_filter_remove": "Filter entfernen", + "datepicker_open": "Datumsauswahl öffnen", + "datepicker_label": "Datumsauswahl", + "datepicker_previous_month": "Vorheriger Monat", + "datepicker_next_month": "Nächster Monat", + "datepicker_day_select": "{date} auswählen", "timestamp_just_now": "gerade eben", "timestamp_minutes_ago": [ { @@ -432,5 +849,38 @@ "n=other": "vor {count} J." } } - ] + ], + "storage_transfer_reason_same_location": "Es befindet sich bereits an diesem Ort.", + "storage_transfer_reason_into_itself": "Ein Ordner kann nicht in sich selbst abgelegt werden.", + "storage_transfer_reason_overlaps_source": "Das Ziel überschneidet sich mit einem ausgewählten Element.", + "storage_transfer_reason_duplicate_destination": "Ein anderes ausgewähltes Element hat bereits dieses Ziel.", + "storage_transfer_reason_conflict": "Ein Element mit diesem Namen ist bereits vorhanden.", + "storage_transfer_reason_policy_mismatch": "Diese Auswahl gilt nicht für dieses Element.", + "storage_transfer_reason_invalid_name": "Der neue Name ist ungültig.", + "storage_transfer_reason_name_too_long": "Der Name ist zu lang. Der vollständige Pfad darf höchstens 1024 Bytes lang sein.", + "storage_transfer_item_partial": "nur teilweise übertragen", + "storage_transfer_item_failed": "fehlgeschlagen", + "storage_transfer_item_cancelled": "abgebrochen", + "storage_transfer_status_done": "{count} erledigt", + "storage_transfer_status_partial": "{count} teilweise erledigt", + "storage_transfer_status_failed": "{count} fehlgeschlagen", + "storage_transfer_status_refused": "{count} abgelehnt", + "storage_transfer_status_cancelled": "{count} abgebrochen", + "storage_transfer_summary_paste": "Einfügen abgeschlossen: {counts}.", + "storage_transfer_summary_move": "Verschieben abgeschlossen: {counts}.", + "storage_transfer_item_line": "{name}: {reason}", + "storage_transfer_more": "+ {count} weitere", + "storage_transfer_resolution_merge": "Zusammenführen", + "storage_transfer_resolution_keep_both": "Beide behalten", + "storage_transfer_merge_all": "Alle zusammenführen", + "storage_transfer_merge_note": "Beim Zusammenführen bleiben Dateien erhalten, die nur im Ziel existieren. Gleichnamige Dateien werden ersetzt.", + "storage_transfer_refused_title": "Kann nicht übertragen werden", + "storage_transfer_close": "Schließen", + "storage_transfer_too_many": "Es können höchstens {max} Elemente auf einmal eingefügt oder verschoben werden.", + "storage_transfer_other_connection": "Diese Elemente gehören zu einer anderen Speicherverbindung und können hier nicht eingefügt oder verschoben werden.", + "storage_transfer_failed_paste": "Einfügen nicht möglich: {counts}.", + "storage_transfer_failed_move": "Verschieben nicht möglich: {counts}.", + "storage_transfer_status_unknown": "{count} mit unbekanntem Ergebnis", + "storage_transfer_item_unknown": "Ergebnis unbekannt, bitte den Ordner prüfen", + "storage_transfer_running": "Ein Einfüge- oder Verschiebevorgang läuft noch. Bitte warten Sie, bis er abgeschlossen ist." } diff --git a/messages/en.json b/messages/en.json index 8e58ff22..3d9d06a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,5 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "action_close": "Close", + "action_dismiss": "Dismiss", "dashboard_welcome": "Welcome back", "dashboard_subtitle": "Stackable Unified Data Platform overview", "dashboard_services": "Services", @@ -21,6 +23,7 @@ "nav_badge_soon": "Soon", "sidebar_label": "Sidebar", "sidebar_nav_label": "Main navigation", + "navigation_loading": "Loading…", "sidebar_close_nav": "Close navigation", "sidebar_expand": "Expand sidebar", "sidebar_collapse": "Collapse sidebar", @@ -103,7 +106,7 @@ "trino_catalog_error": "Failed to load catalog", "trino_catalog_load_children_error": "Failed to load", "trino_view_in_trino": "View in Trino", - "trino_tab_new": "New tab", + "trino_tab_new": "New Tab", "trino_tab_close": "Close tab {name}", "trino_tab_default_name": "Untitled", "trino_tab_rename": "Rename tab", @@ -154,7 +157,14 @@ "storage_connect_secret_key": "Secret key", "storage_connect_submit": "Connect", "storage_connect_reconnecting": "Reconnecting to storage…", + "storage_connect_timeout": "Connection timed out. The storage backend did not respond.", + "storage_connect_cancel": "Cancel", "storage_connect_error": "Could not connect — check the endpoint and credentials.", + "storage_connect_error_unreachable": "Could not connect to the selected storage — the endpoint may be unreachable.", + "storage_connect_error_access_denied": "Access denied — check your credentials.", + "storage_connect_error_not_found": "Could not find the storage backend — check the endpoint.", + "storage_connect_error_server_error": "The storage backend returned an error — check the endpoint and credentials.", + "storage_connect_error_network": "Could not reach the storage backend — check the host and port.", "storage_connect_error_hdfs": "HDFS connections are not yet supported.", "storage_connect_saved": "Saved connections", "storage_connect_no_saved": "No saved connections yet", @@ -162,6 +172,18 @@ "storage_connect_forget_confirm": "Delete {endpoint} from saved connections?", "storage_connect_forget_cancel": "Cancel", "storage_connect_forget_label": "Delete connection to {endpoint}", + "storage_connect_name": "Connection name", + "storage_connect_name_placeholder": "e.g. Primary S3", + "storage_connect_additional_buckets": "Additional buckets", + "storage_connect_additional_buckets_hint": "Bucket names not returned by ListBuckets, one per line", + "storage_connect_duplicate_warning": "Duplicate credentials detected", + "storage_connect_delete_label": "Delete connection {name}", + "storage_connect_delete_confirm_title": "Delete \"{name}\"?", + "storage_connect_delete_confirm_message": "The saved credentials will be permanently removed.", + "storage_connect_delete_confirm_button": "Delete", + "storage_connect_delete_cancel": "Cancel", + "storage_connect_add_new": "Add new connection", + "storage_connect_testing": "Testing connection…", "storage_connect_copy": "Copy", "storage_connect_copied": "Copied", "storage_connect_copy_all": "Copy all", @@ -184,9 +206,10 @@ "storage_connection_edit_name": "Connection name", "storage_connection_edit_name_placeholder": "e.g. Production Garage", "storage_connection_edit_name_hint": "Optional. Shown on the connection tile instead of the hostname.", + "storage_connection_edit_name_taken": "You already have a connection with this name.", + "storage_connection_edit_secret_required": "Enter the secret key that belongs to the new access key.", "storage_connection_edit_save": "Save changes", "storage_connection_edit_credentials_hint": "Leave blank to keep the current credentials.", - "storage_connection_edit_secret_key_required": "Enter the secret key for the new access key.", "storage_connection_edit_success": "Connection updated successfully.", "storage_connection_edit_active_notice": "You are currently connected with this connection.", "storage_connection_edit_not_found": "Connection not found.", @@ -204,7 +227,18 @@ "storage_buckets_subtitle": "Browse and manage your stored objects.", "storage_view_all_buckets": "View all buckets", "storage_buckets_empty": "No buckets found", + "storage_add_bucket": "Add bucket", + "storage_add_bucket_title": "Connect to a bucket", + "storage_add_bucket_subtitle": "Enter the name of a bucket you have access to.", + "storage_add_bucket_name_label": "Bucket name", + "storage_add_bucket_name_placeholder": "my-bucket", + "storage_add_bucket_submit": "Connect", + "storage_add_bucket_cancel": "Cancel", + "storage_add_bucket_error_access_denied": "Access denied — you do not have permission to read this bucket.", + "storage_add_bucket_error_not_found": "Bucket not found — check the name and try again.", + "storage_add_bucket_error_unknown": "Could not connect to this bucket — check the name and try again.", "storage_bucket_empty": "This bucket is empty", + "storage_folder_empty": "This folder is empty", "storage_folder": "folder", "storage_folders": "folders", "storage_file": "file", @@ -230,10 +264,14 @@ } ], "storage_select_toggle": "Toggle selection mode", + "storage_select_item": "Select {name}", + "storage_select_all": "Select all", "storage_paste": "Paste {count} item", "storage_paste_plural": "Paste {count} items", "storage_selected": "{count} selected", + "storage_selected_files_size": "Selected files: {size}", "storage_action_preview": "Preview", + "storage_action_actions_for": "Actions for {name}", "storage_action_refresh": "Refresh", "storage_action_preview_no_selection": "No file selected for preview.", "storage_action_download_no_selection": "No file selected for download.", @@ -245,10 +283,14 @@ "storage_action_delete": "Delete", "storage_action_paste": "Paste", "storage_action_pin": "Pin this location", + "storage_action_details": "Details", "storage_action_unpin": "Unpin", "storage_action_copy_filename": "Copy filename", "storage_action_copy_filename_success": "Filename copied to clipboard.", "storage_action_copy_filename_error": "Could not copy filename to clipboard.", + "storage_action_copy_directory_name": "Copy directory name", + "storage_action_copy_directory_name_success": "Directory name copied to clipboard.", + "storage_action_copy_directory_name_error": "Could not copy directory name to clipboard.", "storage_action_copy_path": "Copy path", "storage_action_copy_path_success": "Path copied to clipboard.", "storage_action_copy_path_error": "Could not copy path to clipboard.", @@ -271,10 +313,13 @@ "storage_parent_dir": "Parent directory", "storage_loading": "Loading…", "storage_breadcrumb_more": "More folders", + "storage_breadcrumb_label": "Breadcrumb", + "storage_error_breadcrumb": "breadcrumb", "storage_error_title": "Storage error", - "storage_error_access_denied": "You do not have permission to access the bucket \u201c{bucket}\u201d.", - "storage_error_not_found": "The bucket \u201c{bucket}\u201d does not exist.", - "storage_error_storage_error": "An error occurred while accessing the bucket \u201c{bucket}\u201d.", + "storage_error_access_denied": "You do not have permission to access the bucket “{bucket}”.", + "storage_error_not_connected": "You are not connected to the storage “{connection}”. Connect to it to open this link.", + "storage_error_not_found": "The bucket “{bucket}” does not exist.", + "storage_error_storage_error": "An error occurred while accessing the bucket “{bucket}”.", "storage_error_back_to_storage": "Back to storage", "storage_error_go_back": "Go back", "storage_download_error_title": "Download failed", @@ -284,6 +329,17 @@ "storage_download_error_server_error": "A server error occurred while downloading the file. Please try again.", "storage_download_error_unknown": "An unexpected error occurred. Please try again.", "storage_download_dismiss": "Dismiss", + "storage_download_history_title": "Download history", + "storage_download_history_empty": "No downloads in the last 30 days.", + "storage_download_history_file_count": "{count} files", + "storage_download_history_zip": "ZIP", + "storage_download_history_direct": "Individual files", + "storage_download_history_show_details": "Show details", + "storage_download_history_hide_details": "Hide details", + "storage_download_history_file_not_found": "Failed: File \"{path}\" cannot be found", + "storage_download_history_selected_count": "{count} selected", + "storage_download_history_download_selected": "Download selected", + "storage_download_history_download_all": "Download all", "storage_delete_confirm_title_one": "Delete \"{name}\"?", "storage_delete_confirm_title_many": "Delete {count} items?", "storage_delete_confirm_message": "This action cannot be undone.", @@ -295,6 +351,8 @@ "storage_delete_error_access_denied": "Access denied. You do not have permission to delete this item.", "storage_delete_error_server_error": "A server error occurred while deleting. Please try again.", "storage_delete_error_unknown": "An unexpected error occurred while deleting. Please try again.", + "storage_delete_success_one": "Deleted \"{name}\".", + "storage_delete_success_many": "Deleted {count} items.", "storage_delete_partial_failure": [ { "declarations": ["input count", "local countPlural = count: plural"], @@ -324,30 +382,57 @@ "storage_preview_unsupported_desc": "This file type cannot be previewed inline.", "storage_preview_binary_title": "Binary content", "storage_preview_binary_desc": "This file appears to contain binary data and cannot be displayed as text.", + "storage_preview_7zip_unsupported_desc": "7zip archives are not supported.", + "storage_preview_rar_unsupported_desc": "RAR archives are not supported.", "storage_preview_error_title": "Preview failed", "storage_preview_error_desc": "An error occurred while loading the preview.", "storage_preview_access_denied": "You do not have permission to preview this file.", "storage_preview_not_found": "The file could not be found.", "storage_preview_image_alt": "Preview of {name}", "storage_preview_csv_columns": "{count} more columns", + "storage_preview_csv_label": "CSV preview", + "storage_preview_parquet_label": "Parquet preview", + "storage_preview_text_label": "File content preview", "storage_preview_csv_rows": "Showing first {count} rows", + "storage_preview_csv_rows_complete": "Showing all {count} rows", "storage_preview_parquet_rows": "Showing first {count} of {total} rows (parquet)", + "storage_preview_infinite_scroll_disabled": "This is the end of the preview. Download the full file or ask the administrator to enable infinite scrolling to load more rows in this preview.", "storage_preview_pdf_too_large": "PDF is too large to preview inline.", "storage_preview_image_too_large": "Image is too large to display.", "storage_preview_image_too_large_desc": "The file exceeds the maximum preview size. Download the full file to view it.", "storage_preview_maximise": "Maximise", "storage_preview_restore": "Restore", + "storage_editor_save": "Save", + "storage_editor_save_action": "Save file", + "storage_editor_label": "Text editor", + "storage_editor_saving": "Saving…", + "storage_editor_saved": "Saved", + "storage_editor_unsaved_title": "Unsaved changes", + "storage_editor_unsaved_desc": "You have unsaved changes. What would you like to do?", + "storage_editor_save_and_close": "Save and close", + "storage_editor_discard": "Discard", + "storage_editor_cancel": "Cancel", + "storage_editor_error": "Failed to save file.", + "storage_editor_too_large": "File too large to save. The maximum editable file size is {limit}.", + "storage_editor_too_large_badge": "Read-only: exceeds {limit}", + "storage_editor_readonly_truncated": "Read-only: only the beginning of this file was loaded", + "storage_editor_readonly_encoding": "Read-only: this file is not UTF-8 text and cannot be saved without changing its encoding", + "storage_editor_readonly_archive": "Read-only: files inside archives cannot be edited", + "storage_editor_readonly_no_etag": "Read-only: the version of this file could not be determined, so changes could overwrite someone else's edits", + "storage_editor_conflict": "Not saved: the file was changed or deleted after you opened it. Copy your changes, then reopen the file.", "storage_context_menu_actions": "Actions", "storage_action_upload": "Upload", "storage_upload_title": "Upload", "storage_upload_drop_prompt": "Drop files or a folder here, or click to browse", + "storage_upload_browser_drop_prompt": "Drop files or a folder to upload to the current folder", "storage_upload_select_files": "Select files", "storage_upload_select_folder": "Select folder", "storage_upload_selected": "Selected: {name}", "storage_upload_to_prefix": "Uploading to: {prefix}", "storage_upload_total_size": "Total: {size}", "storage_upload_start": "Upload", - "storage_upload_checking": "Checking for conflicts\u2026", + "storage_upload_checking": "Checking for conflicts…", + "storage_upload_check_error": "Could not check for existing files. Nothing was uploaded. Please try again.", "storage_upload_conflicts_title": "Files already exist", "storage_upload_conflicts_desc": "Choose what to do for each conflicting file.", "storage_upload_skip_all": "Skip all", @@ -359,13 +444,13 @@ "storage_upload_rename_same_name_error": "Please enter a different name, or select Replace or Skip.", "storage_upload_rename_confirm_action": "Confirm name", "storage_upload_rename_taken": "This name already exists here. Please choose a different name.", - "storage_upload_uploading": "Uploading\u2026 {pct}%", + "storage_upload_uploading": "Uploading… {pct}%", "storage_upload_status_queued": "queued", "storage_upload_status_done": "done", "storage_upload_status_skipped": "skipped", "storage_upload_status_failed": "failed", "storage_upload_complete_title": "Upload complete", - "storage_upload_complete_summary": "{uploaded} uploaded \u00b7 {skipped} skipped \u00b7 {failed} failed", + "storage_upload_complete_summary": "{uploaded} uploaded · {skipped} skipped · {failed} failed", "storage_upload_done": "Done", "storage_upload_success": "File uploaded successfully.", "storage_upload_error_not_connected": "No storage connection configured. Please connect first.", @@ -375,13 +460,345 @@ "storage_upload_error_server_error": "A server error occurred. Please retry.", "storage_upload_error_unknown": "An unexpected error occurred. Please retry.", "storage_upload_overwrite_title": "File already exists", - "storage_upload_overwrite_message": "An object named \u201c{name}\u201d already exists in this location. What would you like to do?", + "storage_upload_overwrite_message": "An object named “{name}” already exists in this location. What would you like to do?", "storage_upload_overwrite_replace": "Replace", "storage_upload_overwrite_cancel": "Cancel", "storage_upload_overwrite_rename": "Rename", "storage_upload_rename_confirm": "Upload with this name", "storage_upload_retry": "Retry", + "storage_upload_leave_title": "Upload in progress", + "storage_upload_leave_desc": "Leaving this page stops the upload. Files that have not finished uploading are not stored.", + "storage_upload_leave_stay": "Stay", + "storage_upload_leave_confirm": "Stop upload and leave", "storage_upload_close": "Close", + "storage_action_create": "Create", + "storage_create_file": "New text file", + "storage_create_folder": "New directory", + "storage_create_name": "Name", + "storage_create_placeholder": "Enter name…", + "storage_create_confirm": "Create", + "storage_create_cancel": "Cancel", + "storage_create_error": "Failed to create {name}", + "storage_name_required": "Enter a name.", + "storage_name_empty_segment": "Remove the extra \"/\". A name cannot start or end with \"/\" or contain \"//\".", + "storage_name_invalid_single": "Enter a single name without \"/\", not \".\" or \"..\", and without the characters < > \\ | ? \" or control characters such as tabs or line breaks.", + "storage_name_invalid_path": "Enter a name without \".\" or \"..\" as a path part, and without the characters < > \\ | ? \" or control characters such as tabs or line breaks.", + "storage_name_too_long": "This name is too long. The full path can be at most 1024 bytes.", + "storage_tab_new": "New Tab", + "storage_tab_close": "Close tab", + "storage_tab_rename": "Rename tab", + "storage_tab_list": "Explorer tabs", + "storage_tab_context_menu": "Tab options", + "storage_restore_tabs_message": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "You have 1 saved tab from your last session.", + "countPlural=other": "You have {count} saved tabs from your last session." + } + } + ], + "storage_restore_tabs_action": "Restore tabs", + "storage_preview_parquet_tab_aria": "Parquet preview tabs", + "storage_preview_parquet_tab_metadata": "Metadata", + "storage_preview_parquet_tab_data": "Data", + "storage_preview_parquet_blocked_title": "Preview blocked", + "storage_preview_parquet_blocked_desc": "This parquet file uses a compression type that has been disallowed by the administrator. Contact your administrator if you believe this to be an issue.", + "storage_preview_parquet_overview": "Overview", + "storage_preview_parquet_file_size": "File size", + "storage_preview_parquet_total_rows": "Total rows", + "storage_preview_parquet_total_columns": "Total columns", + "storage_preview_parquet_version": "Version", + "storage_preview_parquet_created_by": "Created by", + "storage_preview_parquet_schema": "Schema", + "storage_preview_parquet_column": "Column", + "storage_preview_parquet_type": "Type", + "storage_preview_parquet_compression_indexes": "Compression & Indexes", + "storage_preview_parquet_row_groups": "Row groups", + "storage_preview_parquet_row_group": "Group", + "storage_preview_parquet_offset_index": "Offset index", + "storage_preview_parquet_column_index": "Column index", + "storage_preview_parquet_available": "Available", + "storage_preview_parquet_missing": "Missing", + "storage_preview_parquet_none": "None", + "storage_preview_parquet_compression": "Compression", + "storage_preview_parquet_uncompressed": "Uncompressed", + "storage_preview_parquet_compressed": "Compressed", + "storage_preview_parquet_size": "Size", + "storage_preview_parquet_statistics": "Statistics", + "storage_preview_parquet_min": "Min", + "storage_preview_parquet_max": "Max", + "storage_preview_parquet_null_count": "Null count", + "storage_preview_parquet_distinct_count": "Distinct count", + "storage_preview_parquet_not_stored": "Not stored", + "storage_preview_parquet_arrow_schema": "Arrow schema", + "storage_preview_parquet_arrow_schema_present": "Embedded Arrow schema ({size})", + "storage_preview_parquet_uniform": "uniform", + "storage_archive_open_error": "Could not open archive. The format may be unsupported or the archive is corrupt.", + "storage_archive_too_large": "Archive is too large to preview.", + "storage_archive_exit": "Exit archive", + "storage_details_title": "Details", + "storage_details_file_title": "File Details", + "storage_details_folder_title": "Directory Details", + "storage_details_bucket_title": "Bucket Details", + "storage_details_close": "Close", + "storage_details_maximise": "Maximise", + "storage_details_restore": "Restore", + "storage_details_property": "Property", + "storage_details_value": "Value", + "storage_details_name": "Name", + "storage_details_size": "Size", + "storage_details_last_modified": "Last modified", + "storage_details_content_type": "Content type", + "storage_details_etag": "ETag", + "storage_details_version_id": "Version ID", + "storage_details_storage_class": "Storage class", + "storage_details_is_delete_marker": "Delete marker", + "storage_details_yes": "Yes", + "storage_details_no": "No", + "storage_details_calculate_size": "Calculate directory size", + "storage_details_calculating": "Calculating…", + "storage_details_calculating_desc": "Listing all objects in this directory…", + "storage_details_keys_found": "{count} objects found", + "storage_details_file_count": "{count} files", + "storage_details_folder_count": "{count} folders", + "storage_details_total_size": "Total size", + "storage_details_tree_visualization": "Size composition", + "storage_details_versioning": "Versioning", + "storage_details_versioning_enabled": "Enabled", + "storage_details_versioning_suspended": "Suspended", + "storage_details_versioning_disabled": "Disabled", + "storage_details_lifecycle_rules": "Lifecycle Rules", + "storage_details_lifecycle_rule_id": "Rule ID", + "storage_details_lifecycle_status": "Status", + "storage_details_lifecycle_filter": "Filter", + "storage_details_lifecycle_expiration": "Expiration", + "storage_details_lifecycle_noncurrent_expiration": "Noncurrent version expiration", + "storage_details_lifecycle_transition": "Transition", + "storage_details_lifecycle_days": "{days} days", + "storage_details_lifecycle_abort_mpu": "Abort incomplete uploads", + "storage_details_lifecycle_days_after_initiation": "{days}d after initiation", + "storage_details_lifecycle_noncurrent_transition": "Noncurrent transition", + "storage_details_lifecycle_transition_to": "→ {storageClass}", + "storage_details_lifecycle_prefix_filter": "Prefix: {prefix}", + "storage_details_lifecycle_all_objects": "All objects", + "storage_details_lifecycle_no_rules": "No lifecycle rules configured", + "storage_details_tags": "Tags", + "storage_details_no_tags": "No tags", + "storage_details_unavailable": "Could not load", + "storage_details_custom_metadata": "Custom Metadata", + "storage_details_error_not_connected": "No storage connection configured", + "storage_details_error_fetch_file": "Failed to fetch file details ({status})", + "storage_details_error_fetch_bucket": "Failed to fetch bucket details ({status})", + "storage_details_error_fetch_dir_meta": "Failed to fetch directory metadata ({status})", + "storage_details_error_calc_size": "Failed to calculate directory size ({status})", + "storage_details_error_unknown": "Unknown error", + "storage_details_error_no_body": "No response body", + "storage_details_unnamed_rule": "(unnamed)", + "storage_details_treemap_aria": "Treemap visualization of directory size composition", + "storage_details_treemap_more": "{count} more", + "storage_details_copy_dir_name": "Copy directory name", + "storage_details_copy_file_name": "Copy file name", + "storage_details_copy_full_path": "Copy full path", + "storage_details_tab_overview": "Overview", + "storage_details_tab_composition": "Size composition", + "storage_details_tab_contents": "Contents", + "storage_details_tab_lifecycle": "Lifecycle", + "storage_details_file_path": "Path", + "storage_details_bucket_name": "Bucket", + "storage_details_owner": "Owner", + "storage_details_permissions": "Permissions", + "storage_details_depth": "Depth", + "storage_details_marker_exists": "Folder marker", + "storage_details_marker_none": "No folder marker object", + "storage_details_encryption": "Encryption", + "storage_details_object_lock": "Object lock", + "storage_details_object_lock_mode": "Lock mode", + "storage_details_object_lock_until": "Retain until", + "storage_details_truncated": "Stopped after {count} objects. Size, counts and breakdown only cover the objects listed so far.", + "storage_details_list_omitted": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 smaller entry not shown", + "countPlural=other": "{count} smaller entries not shown" + } + } + ], + "storage_action_cut_success": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 item cut", + "countPlural=other": "{count} items cut" + } + } + ], + "storage_action_copy_success": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 item copied", + "countPlural=other": "{count} items copied" + } + } + ], + "storage_action_paste_success": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 item pasted", + "countPlural=other": "{count} items pasted" + } + } + ], + "storage_action_paste_error": "Could not paste items. Please try again.", + "storage_action_paste_archive_error": "Cannot paste inside an archive.", + "storage_action_move_success": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 item moved", + "countPlural=other": "{count} items moved" + } + } + ], + "storage_action_move_error": "Could not move items. Please try again.", + "storage_action_move_error_not_connected": "No storage connection configured. Please connect first.", + "storage_action_move_error_access_denied": "Access denied. You do not have permission to move items.", + "storage_move_confirm_title": "Move items", + "storage_move_confirm_body": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "Move 1 item to \"{destination}\"?", + "countPlural=other": "Move {count} items to \"{destination}\"?" + } + } + ], + "storage_move_confirm_label": "Move", + "storage_move_confirm_cancel": "Cancel", + "storage_move_confirm_total_size": "Total: {size}", + "storage_operation_move_one": "Move {count} file", + "storage_operation_move_other": "Move {count} files", + "storage_operation_paste_one": "Paste {count} file", + "storage_operation_paste_other": "Paste {count} files", + "storage_operation_rename": "Rename", + "storage_operation_done": "Done", + "storage_operation_failed": "Failed", + "storage_operation_cancelled": "Cancelled", + "storage_operation_interrupted": "Interrupted", + "storage_operation_unknown": "Status unknown", + "storage_operations_label": "Operations", + "storage_operations_cancel": "Cancel operation", + "storage_operations_progress": "{completed} of {total} done", + "storage_operations_error_generic": "An error occurred", + "storage_operations_active": "Active", + "storage_operations_history": "History", + "storage_operations_clear_history": "Clear history", + "storage_operations_interrupted_tooltip": "This operation was interrupted by a page reload", + "storage_operations_unknown_tooltip": "The server no longer has a record of this operation, so its outcome is unknown", + "storage_operations_speed": "Speed", + "storage_operations_remaining": "remaining", + "storage_action_rename_inline_label": "New name", + "storage_action_rename_inline_cancel": "Cancel", + "storage_rename_success": "Renamed to \"{name}\".", + "storage_rename_error": "Could not rename \"{name}\". Please try again.", + "storage_rename_error_not_connected": "No storage connection configured. Please connect first.", + "storage_rename_error_access_denied": "Access denied. You do not have permission to rename.", + "storage_rename_error_not_found": "The item could not be found. It may have been moved or deleted.", + "storage_rename_error_conflict": "A file or folder named \"{name}\" already exists in this location.", + "storage_search_open": "Open search", + "storage_search_title": "Search storage", + "storage_search_close": "Close search", + "storage_search_query_label": "Search query", + "storage_search_query_placeholder": "Search files and directories", + "storage_search_scope_label": "Bucket", + "storage_search_submit": "Search", + "storage_search_cancel": "Cancel", + "storage_search_recent_heading": "Recent searches", + "storage_search_recent_empty": "Your recent searches will appear here.", + "storage_search_recent_clear": "Clear recent searches", + "storage_search_recent_clear_confirm_title": "Clear recent searches?", + "storage_search_recent_clear_confirm_message": "This will remove all recent searches for this connection. This action cannot be undone.", + "storage_search_recent_clear_cancel": "Cancel", + "storage_search_recent_clear_confirm": "Clear", + "storage_search_view_label": "Search view", + "storage_search_tab_search": "Search", + "storage_search_tab_recent": "Recent", + "storage_search_no_results": "No results found.", + "storage_search_failure_access_denied": "Access to one or more selected buckets was denied.", + "storage_search_failure_generic": "One or more selected buckets could not be searched. Please try again.", + "storage_search_failure_too_many_results": "The search found more than 10,000 results; only the first ones are shown. Narrow the search to see all matches.", + "storage_search_session_label": "Search {number}", + "storage_search_running": "{count} running", + "storage_search_completed": "{count} completed", + "storage_search_partial_count": "{count} partial", + "storage_search_add_session": "Parallel search", + "storage_search_sessions_label": "Search sessions", + "storage_search_remove_session": "Remove {name}", + "storage_search_status_running": "Running", + "storage_search_status_done": "Done", + "storage_search_status_partial": "Partial", + "storage_search_status_cancelled": "Cancelled", + "storage_search_status_error": "Error", + "storage_search_regex_placeholder": "Regular expression", + "storage_search_regex_badge": ".*", + "storage_search_regex_invalid": "This regular expression is invalid or uses unsupported constructs.", + "storage_search_all_buckets": "all buckets", + "storage_search_scope_all": "All buckets", + "storage_search_scope_filter_label": "Filter buckets", + "storage_search_scope_filter_placeholder": "Search buckets", + "storage_search_scope_no_results": "No buckets found", + "storage_search_scope_toggle": "Include {bucket} in search", + "storage_search_recent_bucket_count": "{count} buckets", + "storage_search_recent_more_buckets": "{count} more buckets", + "storage_search_recent_toggle_buckets": "Choose buckets", + "storage_search_recent_bucket_all": "All buckets", + "storage_search_recent_bucket_toggle": "Include {bucket} in this search", + "storage_search_recent_use_selected": "Search selected buckets", + "storage_search_advanced_options": "Advanced options", + "storage_search_regex_label": "Regular expression search", + "storage_search_exclude_patterns": "Exclude patterns", + "storage_search_exclude_placeholder": "e.g. _temp", + "storage_search_remove_exclude": "Remove {pattern}", + "storage_search_filters_label": "Filters", + "storage_search_filter_add": "Add filter", + "storage_search_path_prefix": "Search path prefix", + "storage_search_path_placeholder": "e.g. events/2024/", + "storage_search_max_depth": "Max folder depth", + "storage_search_max_depth_invalid": "Enter a whole number from 1 to 20.", + "storage_search_depth_placeholder": "unlimited", + "storage_search_searching": "Searching...", + "storage_search_results_elapsed": "{count} results · {elapsed}ms", + "storage_search_result_type": "Type", + "storage_search_keyboard_hint": "Enter: search · Alt+N: new session · Esc: close", + "storage_search_key_enter": "Enter", + "storage_search_key_escape": "Esc", + "storage_search_shortcut_search": "search", + "storage_search_shortcut_close": "close", + "storage_search_filter_field_label": "Filter field", + "storage_search_filter_date": "Date", + "storage_search_filter_size": "Size", + "storage_search_filter_invalid": "Enter a valid filter value", + "storage_search_filter_operator": "Filter operator", + "storage_search_filter_value_size": "Size filter value", + "storage_search_filter_size_placeholder": "e.g. 10 MB", + "storage_search_filter_value_date": "Date filter value", + "storage_search_filter_date_placeholder": "e.g. 2024-01-31", + "storage_search_filter_remove": "Remove filter", + "datepicker_open": "Open date picker", + "datepicker_label": "Date picker", + "datepicker_previous_month": "Previous month", + "datepicker_next_month": "Next month", + "datepicker_day_select": "Select {date}", "timestamp_just_now": "just now", "timestamp_minutes_ago": [ { @@ -432,5 +849,38 @@ "n=other": "{count}y ago" } } - ] + ], + "storage_transfer_reason_same_location": "It is already in this location.", + "storage_transfer_reason_into_itself": "A folder cannot be placed inside itself.", + "storage_transfer_reason_overlaps_source": "The destination overlaps a selected item.", + "storage_transfer_reason_duplicate_destination": "Another selected item already goes to this destination.", + "storage_transfer_reason_conflict": "An item with this name already exists.", + "storage_transfer_reason_policy_mismatch": "This choice does not apply to this item.", + "storage_transfer_reason_invalid_name": "The new name is not valid.", + "storage_transfer_reason_name_too_long": "The name is too long. The full path can be at most 1024 bytes.", + "storage_transfer_item_partial": "only partly transferred", + "storage_transfer_item_failed": "failed", + "storage_transfer_item_cancelled": "cancelled", + "storage_transfer_status_done": "{count} done", + "storage_transfer_status_partial": "{count} partly done", + "storage_transfer_status_failed": "{count} failed", + "storage_transfer_status_refused": "{count} refused", + "storage_transfer_status_cancelled": "{count} cancelled", + "storage_transfer_summary_paste": "Paste finished: {counts}.", + "storage_transfer_summary_move": "Move finished: {counts}.", + "storage_transfer_item_line": "{name}: {reason}", + "storage_transfer_more": "+ {count} more", + "storage_transfer_resolution_merge": "Merge", + "storage_transfer_resolution_keep_both": "Keep both", + "storage_transfer_merge_all": "Merge all", + "storage_transfer_merge_note": "Merging keeps files that exist only in the destination. Files with the same name are replaced.", + "storage_transfer_refused_title": "Cannot be transferred", + "storage_transfer_close": "Close", + "storage_transfer_too_many": "At most {max} items can be pasted or moved at once.", + "storage_transfer_other_connection": "These items belong to another storage connection and cannot be pasted or moved here.", + "storage_transfer_failed_paste": "Could not paste: {counts}.", + "storage_transfer_failed_move": "Could not move: {counts}.", + "storage_transfer_status_unknown": "{count} with unknown outcome", + "storage_transfer_item_unknown": "outcome unknown, check the folder", + "storage_transfer_running": "A paste or move is still running. Wait until it has finished." } diff --git a/package-lock.json b/package-lock.json index 30ff68d9..b9707f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,17 +11,24 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1041.0", "@aws-sdk/lib-storage": "^3.1045.0", + "@better-auth/drizzle-adapter": "^1.6.20", "@internationalized/date": "^3.11.0", "@smithy/node-http-handler": "4.9.1", + "adm-zip": "^0.6.1", "antlr4-c3": "^3.4.4", "antlr4ng": "^3.0.16", "better-auth": "^1.6.33", + "drizzle-orm": "^0.45.2", "hyparquet": "^1.25.8", "hyparquet-compressors": "^1.1.1", "monaco-editor": "^0.55.1", + "papaparse": "^5.5.3", + "pg": "^8.21.0", "pino": "^10.3.1", "pretty-bytes": "^7.1.0", "prom-client": "^15.1.3", + "regexpp": "^3.2.0", + "tar-stream": "^3.2.0", "undici": "^7.24.4" }, "devDependencies": { @@ -29,21 +36,34 @@ "@eslint/compat": "^2.0.2", "@eslint/js": "^9.39.2", "@faker-js/faker": "^10.4.0", + "@iconify-json/lsicon": "^1.2.5", + "@iconify-json/material-icon-theme": "^1.2.67", "@iconify-json/material-symbols": "^1.2.74", + "@iconify-json/vscode-icons": "^1.2.55", "@inlang/paraglide-js": "^2.12.0", "@playwright/test": "1.61.1", "@sveltejs/adapter-node": "^5.5.3", "@sveltejs/kit": "^2.53.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", + "@testcontainers/postgresql": "^12.0.1", + "@types/adm-zip": "^0.5.8", "@types/node": "^24", + "@types/papaparse": "^5.5.2", + "@types/pg": "^8.20.0", + "@types/tar-stream": "^3.1.4", "@vitest/browser-playwright": "^4.1.11", "@vitest/coverage-v8": "^4.1.11", "antlr-ng": "^1.0.10", + "archunit": "^2.3.3", "daisyui": "^5.5.19", + "drizzle-kit": "^0.31.10", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-better-tailwindcss": "^4.3.0", + "eslint-plugin-check-file": "^3.3.2", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-security": "^4.0.0", "eslint-plugin-svelte": "^3.15.0", "globals": "^16.5.0", @@ -56,6 +76,7 @@ "svelte-check": "^4.4.3", "sveltekit-superforms": "^2.30.0", "tailwindcss": "^4.2.1", + "testcontainers": "^12.0.1", "tsx": "^4.23.13", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", @@ -540,6 +561,13 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -1016,6 +1044,13 @@ "license": "MIT", "optional": true }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1050,10 +1085,22 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", "cpu": [ "x64" ], @@ -1063,1490 +1110,1761 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "devOptional": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=12" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=12" } }, - "node_modules/@eslint/compat": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.1.tgz", - "integrity": "sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "peerDependencies": { - "eslint": "^8.40 || 9 || 10" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "node": ">=12" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=12" } }, - "node_modules/@eslint/css-tree": { - "version": "3.6.9", - "resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-3.6.9.tgz", - "integrity": "sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==", - "dev": true, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "mdn-data": "2.23.0", - "source-map-js": "^1.0.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": ">=12" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", - "dev": true, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=12" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", - "dev": true, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=12" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@exodus/schemasafe": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", - "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@faker-js/faker": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.6.0.tgz", - "integrity": "sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", - "npm": ">=10" + "node": ">=12" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", "optional": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=18.18.0" + "node": ">=12" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=12" } }, - "node_modules/@iconify-json/material-symbols": { - "version": "1.2.93", - "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.93.tgz", - "integrity": "sha512-IfbpRixmEdeBrl2Bc+RtNZGb3NcTnMiPP4WausCParQYpIdezEYYbgMggJC1cYzm1q0f77p53t5tppy9PWsrvQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@iconify/types": "*" + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@iconify/utils": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", - "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", - "dev": true, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@antfu/install-pkg": "^1.1.0", - "@iconify/types": "^2.0.0", - "import-meta-resolve": "^4.2.0" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@inlang/paraglide-js": { - "version": "2.25.4", - "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.25.4.tgz", - "integrity": "sha512-z8OoKAcPT0DwlfTQ/SvaPBQILrtmkl4lUPCSQPjKxqERRp1n7W7a2eKi7EvfylatWivSZFGHGjlB6Ad20w0nzw==", - "dev": true, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "license": "MIT", "dependencies": { - "@inlang/recommend-sherlock": "^0.2.1", - "@inlang/sdk": "^3.0.6", - "commander": "11.1.0", - "consola": "3.4.0", - "jiti": "^2.7.0", - "json5": "2.2.3", - "unplugin": "^2.1.2", - "urlpattern-polyfill": "^10.0.0", - "valibot": "^1.4.2" + "eslint-visitor-keys": "^3.4.3" }, - "bin": { - "paraglide-js": "bin/run.js" + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" }, "peerDependencies": { - "typescript": ">=5.6", - "vite": ">=5.0.0" + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "vite": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@inlang/recommend-sherlock": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", - "integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "dependencies": { - "comment-json": "^4.2.3" + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@inlang/sdk": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-3.0.6.tgz", - "integrity": "sha512-JyUxQNjKNWglT8qhInmenKK/AnKkbX9AhAM8vFnTT8SyQ0gFA2YGKPntGp+3+UZP9F06Nmawf4sFTn7nGIyTXQ==", + "node_modules/@eslint/compat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.1.tgz", + "integrity": "sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@lix-js/sdk": "0.17.0", - "@sinclair/typebox": "^0.31.17", - "kysely": "^0.28.12", - "uuid": "^14.0.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": ">=20.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^8.40 || 9 || 10" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@internationalized/date": { - "version": "3.12.4", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz", - "integrity": "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==", + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@swc/helpers": "^0.5.0" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "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==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "ISC", - "optional": true, + "license": "Apache-2.0", "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" + "@eslint/core": "^0.17.0" }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "ISC", - "optional": true, + "license": "Apache-2.0", "dependencies": { - "minipass": "^7.0.4" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.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==", - "devOptional": true, - "license": "MIT", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "devOptional": true, + "node_modules/@eslint/css-tree": { + "version": "3.6.9", + "resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-3.6.9.tgz", + "integrity": "sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==", + "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "devOptional": true, - "license": "MIT", + "mdn-data": "2.23.0", + "source-map-js": "^1.0.1" + }, "engines": { - "node": ">=6.0.0" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "devOptional": true, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@lix-js/sdk": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.17.0.tgz", - "integrity": "sha512-xplbKYEokKbm0/soUy9eXlROF55Si1J9HK97iwJrNl/av1VixZ2mvpDTa0QsX96UyxFnzkXru6hngjtIJTl73g==", + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "dependencies": { - "@bytecodealliance/jco-transpile": "0.13.0", - "binaryen": "130.0.0", - "fflate": "^0.8.3" + "engines": { + "node": ">=18" }, - "optionalDependencies": { - "@lix-js/sdk-darwin-arm64": "0.17.0", - "@lix-js/sdk-linux-arm64": "0.17.0", - "@lix-js/sdk-linux-x64": "0.17.0", - "@lix-js/sdk-win32-x64": "0.17.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@lix-js/sdk-darwin-arm64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@lix-js/sdk-darwin-arm64/-/sdk-darwin-arm64-0.17.0.tgz", - "integrity": "sha512-+r74U7uBNfzgHEkxJAo4VkwQugqfk3MYZ5GHUIZnWZyyU1+oecXTmeepiaMnmHuflMPVv6ZfE4Yl8eM0Wyn+lw==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, - "optional": true, - "os": [ - "darwin" - ] + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } }, - "node_modules/@lix-js/sdk-linux-arm64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@lix-js/sdk-linux-arm64/-/sdk-linux-arm64-0.17.0.tgz", - "integrity": "sha512-4HqHHsWq1gfPgkReR43Sy18T10JmRSo+VVR3Kqr/xu5zD/2ssoGdtl9QEpC80KVn4pCT5vDCysT1UOwEJkv7iA==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } }, - "node_modules/@lix-js/sdk-linux-x64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@lix-js/sdk-linux-x64/-/sdk-linux-x64-0.17.0.tgz", - "integrity": "sha512-968kHRX0waV5UoR4i0HZkqNr5KDOWPd4he3miLImCMGsQDWLViYde613Bf4hxas/SouCSfmnvlAnl87oSAZc8w==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } }, - "node_modules/@lix-js/sdk-win32-x64": { + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@lix-js/sdk-win32-x64/-/sdk-win32-x64-0.17.0.tgz", - "integrity": "sha512-/uYvg0c5R9+BtQ1aJqBE9VCwNSYSaZORUjo5u6ukAFkH7XuN1ZnpTzoI9K6HE82/dxlCymMsP3Ga/rQVpQn4hA==", - "cpu": [ - "x64" - ], + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "optional": true, - "os": [ - "win32" - ] + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", + "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" + "optional": true + }, + "node_modules/@faker-js/faker": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.6.0.tgz", + "integrity": "sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } ], + "license": "MIT", "engines": { - "node": "^22.20 || ^24.12 || >=25" + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "node_modules/@grpc/grpc-js": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "dev": true, - "license": "MIT", - "optional": true, + "license": "Apache-2.0", "dependencies": { - "@tybys/wasm-util": "^0.10.3" + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + "node": ">=12.10.0" } }, - "node_modules/@noble/ciphers": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", - "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" } }, - "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "node_modules/@grpc/grpc-js/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.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=8" } }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "node_modules/@grpc/grpc-js/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", - "optional": true, "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=12" } }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "node_modules/@grpc/grpc-js/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": "ISC", - "optional": true, + "license": "MIT" + }, + "node_modules/@grpc/grpc-js/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": { - "semver": "^7.3.5" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=8" } }, - "node_modules/@oozcitak/dom": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", - "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", + "node_modules/@grpc/grpc-js/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": { - "@oozcitak/infra": "^2.0.2", - "@oozcitak/url": "^3.0.0", - "@oozcitak/util": "^10.0.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=20.0" + "node": ">=8" } }, - "node_modules/@oozcitak/infra": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", - "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", + "node_modules/@grpc/grpc-js/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "@oozcitak/util": "^10.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=20.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@oozcitak/url": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", - "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", + "node_modules/@grpc/grpc-js/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": { - "@oozcitak/infra": "^2.0.2", - "@oozcitak/util": "^10.0.0" + "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": ">=20.0" + "node": ">=12" } }, - "node_modules/@oozcitak/util": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", - "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", + "node_modules/@grpc/grpc-js/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": "MIT", + "license": "ISC", "engines": { - "node": ">=20.0" + "node": ">=12" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, "engines": { - "node": ">=14" + "node": ">=6" } }, - "node_modules/@oxc-minify/binding-android-arm-eabi": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.136.0.tgz", - "integrity": "sha512-gk1jzky1vLeOcK7w9Ib2RT/UooZ7O21s8YSeXCsy4tzsmguKb3Cbsv/Ra2fKhMp4mKeKCzvC1Xa41OfSBWfWdQ==", - "cpu": [ - "arm" - ], + "node_modules/@grpc/proto-loader/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", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=8" } }, - "node_modules/@oxc-minify/binding-android-arm64": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.136.0.tgz", - "integrity": "sha512-5GIYanqud/ohXEcg2dsdn0+eSKXqclw5qlRUkvSM1sKEa4ZQ7hqOk8XPJBNPh3LXXw+Qw85B6jkmOFLKsCHp5w==", - "cpu": [ - "arm64" - ], + "node_modules/@grpc/proto-loader/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", - "optional": true, - "os": [ - "android" - ], + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=12" } }, - "node_modules/@oxc-minify/binding-darwin-arm64": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-darwin-arm64/-/binding-darwin-arm64-0.136.0.tgz", - "integrity": "sha512-QAMpHKXWOoMCTenSZjQjVT5YmMooujlCKAf1T3PHFl2LuaA6ukQFXba0bgSLgAxRrAeX/itkwi4lzVQXtYmnRg==", - "cpu": [ - "arm64" - ], + "node_modules/@grpc/proto-loader/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@grpc/proto-loader/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", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=8" } }, - "node_modules/@oxc-minify/binding-darwin-x64": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-darwin-x64/-/binding-darwin-x64-0.136.0.tgz", - "integrity": "sha512-rDnoPj8hztiU8C1VNKuzujh7SgTJlgiUvUCiWQjTnVSLsABsLW9ZWd1l1Ji0VN3UW39wGpptgvahIWTrHnGH/A==", - "cpu": [ - "x64" - ], + "node_modules/@grpc/proto-loader/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", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=8" } }, - "node_modules/@oxc-minify/binding-freebsd-x64": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-freebsd-x64/-/binding-freebsd-x64-0.136.0.tgz", - "integrity": "sha512-9fGSgP7uyPLCTjMx9z3AUV4UjnfOVoGTH5JzamLegPwGLJ97Gr1jht1437CRk8ZNabGmxQR6qNn3e+Tox2lswg==", - "cpu": [ - "x64" - ], + "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@oxc-minify/binding-linux-arm-gnueabihf": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.136.0.tgz", - "integrity": "sha512-k0QvZS+x6ROI4O5Ikqn9dDU2dmkEJ46Yh6baZJI/eUk/a1k1GOs9/vyXItLZrKlwYrSgdEx2BFXbKgyd1NyRPw==", - "cpu": [ - "arm" - ], + "node_modules/@grpc/proto-loader/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", - "optional": true, - "os": [ - "linux" - ], + "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": "^20.19.0 || >=22.12.0" + "node": ">=12" } }, - "node_modules/@oxc-minify/binding-linux-arm-musleabihf": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.136.0.tgz", - "integrity": "sha512-iRFxUc8ubotVfuyq3e4LPGbHrjvWd1/B3499a55GueMLc1EDJ0fHzNXJiCx2eMQGfzHtHodjmvrJ2BSvSXBNhQ==", - "cpu": [ - "arm" - ], + "node_modules/@grpc/proto-loader/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": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=12" } }, - "node_modules/@oxc-minify/binding-linux-arm64-gnu": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.136.0.tgz", - "integrity": "sha512-hbKxNCXJsULrb6EyG5SumzJKuPfP8Y0UQ/TElRgXVUhyyPf6cBPlF8HMq+MAylF98ACBzV8jBp2Mm62lJyTo+w==", - "cpu": [ - "arm64" - ], + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "license": "BSD-3-Clause", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@hapi/hoek": "^9.0.0" } }, - "node_modules/@oxc-minify/binding-linux-arm64-musl": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.136.0.tgz", - "integrity": "sha512-T+u/CGKW0UXemJeVux4ix6rK+ug3bQCUEbu5hFlX9K61KQNd44rOcrM15mDK99L7USaXwmiwHeIkEDvmmyDMSA==", - "cpu": [ - "arm64" - ], + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.18.0" } }, - "node_modules/@oxc-minify/binding-linux-ppc64-gnu": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.136.0.tgz", - "integrity": "sha512-WNPTN2F448tb49BoqjV9IMRzZWa0WB8GHzrB+bnxKQPOyzz07T6rmDKrr4Hjl81ADGjDYSsr1fgXcjyLQFdMdw==", - "cpu": [ - "ppc64" - ], + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.18.0" } }, - "node_modules/@oxc-minify/binding-linux-riscv64-gnu": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.136.0.tgz", - "integrity": "sha512-MvcCfu8OeabrmNN9wqhh8Gqizg68RQ3iOvXBJmlLMy5p1PC6az9bAwqOk98lsCmKI4I5LpULekOlfxRYh3o5Kw==", - "cpu": [ - "riscv64" - ], + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.18.0" } }, - "node_modules/@oxc-minify/binding-linux-riscv64-musl": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.136.0.tgz", - "integrity": "sha512-rwJXFUFFA0egAULtOOgfW8YhhLhtWrr2qFxftvZ7/U6Xtxiev8i+eCwSNW1RpHiX74QMsJVpRE3ykoefqb1MtQ==", - "cpu": [ - "riscv64" - ], + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@oxc-minify/binding-linux-s390x-gnu": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.136.0.tgz", - "integrity": "sha512-qizAxHmj7VHm5/D5qiIlvzxAS2tlmadDOJ5XFI7zx/wfnk/+fdk5iVaHOv+C4tDE/4wLQyMoBYVIs6R1LeN91w==", - "cpu": [ - "s390x" - ], + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@oxc-minify/binding-linux-x64-gnu": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.136.0.tgz", - "integrity": "sha512-gcyLAad0UblkEhGjVfx5Mwq63OSx8r04IbcfwzpZYpHisO/vioMsrs4GkDsQZ2Ejc8cdUk6YMIrf9FwNBzh+Dw==", - "cpu": [ - "x64" - ], + "node_modules/@iconify-json/lsicon": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@iconify-json/lsicon/-/lsicon-1.2.5.tgz", + "integrity": "sha512-dXmYFljtE+xYf/QKzs3WYjHjJuJIbagiUsIT5NL5z+qsWrCOHojfoL0inivGSSpGJjRQM18GPjspzzPaTfJVtw==", "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@iconify/types": "*" } }, - "node_modules/@oxc-minify/binding-linux-x64-musl": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-x64-musl/-/binding-linux-x64-musl-0.136.0.tgz", - "integrity": "sha512-QAK6kwBjljX6luYzeppVdRP3gTaFNFfp9iS/V0cCvU43wUZay5xFXBgmzBHYrzZV2MhzURNnXfEZRo9poAxyvA==", - "cpu": [ - "x64" - ], + "node_modules/@iconify-json/material-icon-theme": { + "version": "1.2.70", + "resolved": "https://registry.npmjs.org/@iconify-json/material-icon-theme/-/material-icon-theme-1.2.70.tgz", + "integrity": "sha512-pctYpp6/3f0TfNoX+yqiC1HRBDxL7+ybOe70MrdyvUa4KcGdtpbsyc4E1qHQTkPIkpU7+0kfP2MYU2C7BfmNrw==", "dev": true, - "libc": [ - "musl" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@iconify/types": "*" } }, - "node_modules/@oxc-minify/binding-openharmony-arm64": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-openharmony-arm64/-/binding-openharmony-arm64-0.136.0.tgz", - "integrity": "sha512-nIGxkTOnN9GwslmooxbZ3Ti2RgbX9QLHy0X9uO0pslOT+4dsNVUawBU/6r4M5cWM+ZdRD2tOF4V+n0l8tS59nQ==", - "cpu": [ - "arm64" - ], + "node_modules/@iconify-json/material-symbols": { + "version": "1.2.93", + "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.93.tgz", + "integrity": "sha512-IfbpRixmEdeBrl2Bc+RtNZGb3NcTnMiPP4WausCParQYpIdezEYYbgMggJC1cYzm1q0f77p53t5tppy9PWsrvQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "license": "Apache-2.0", + "dependencies": { + "@iconify/types": "*" } }, - "node_modules/@oxc-minify/binding-wasm32-wasi": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-wasm32-wasi/-/binding-wasm32-wasi-0.136.0.tgz", - "integrity": "sha512-YB3G8AHT6PpDS8/DfjlJrQjywj+/6gCkNBcAJdYzcYbQRIZE6SaTNowiGuZPR1m1jOl1WjcjefniKSmVxzhvRA==", - "cpu": [ - "wasm32" - ], + "node_modules/@iconify-json/vscode-icons": { + "version": "1.2.81", + "resolved": "https://registry.npmjs.org/@iconify-json/vscode-icons/-/vscode-icons-1.2.81.tgz", + "integrity": "sha512-ES/vVycj4IVC7TK8t+9T4Gnca9p/GbuL8A2Va+xWi01HGpyRCrXdRvKAUE8VcQbTkZsfy6Fv0L6TXq9eFj5+Yw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.5" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "@iconify/types": "*" } }, - "node_modules/@oxc-minify/binding-win32-arm64-msvc": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.136.0.tgz", - "integrity": "sha512-UVRH3BlNc2rmla+sbSd5xTF/u+esCqZS6VSWPolZybcBh3TlVe1Sf5MOuHQWUaFoyv5uFOvee4hOfS/14nPvZw==", - "cpu": [ - "arm64" - ], + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "license": "MIT" }, - "node_modules/@oxc-minify/binding-win32-ia32-msvc": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.136.0.tgz", - "integrity": "sha512-zFGkUd82jci0E+isc2O36lVmmXVUDXyEMN2RYIyez+MBNqhdjPLIGx62FCsrJtLKeuiPf/8XkFv977aUtndUng==", - "cpu": [ - "ia32" - ], + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" } }, - "node_modules/@oxc-minify/binding-win32-x64-msvc": { - "version": "0.136.0", - "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.136.0.tgz", - "integrity": "sha512-VHgPwd5xibC9UYxBeq7nURTjY+wxhdumllij6JaCIUVfD9IRcrA66SVlJ5fPNoUXcRp8ZXhJfoIZwVz9wAEGJw==", - "cpu": [ - "x64" - ], + "node_modules/@inlang/paraglide-js": { + "version": "2.25.4", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.25.4.tgz", + "integrity": "sha512-z8OoKAcPT0DwlfTQ/SvaPBQILrtmkl4lUPCSQPjKxqERRp1n7W7a2eKi7EvfylatWivSZFGHGjlB6Ad20w0nzw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@inlang/recommend-sherlock": "^0.2.1", + "@inlang/sdk": "^3.0.6", + "commander": "11.1.0", + "consola": "3.4.0", + "jiti": "^2.7.0", + "json5": "2.2.3", + "unplugin": "^2.1.2", + "urlpattern-polyfill": "^10.0.0", + "valibot": "^1.4.2" + }, + "bin": { + "paraglide-js": "bin/run.js" + }, + "peerDependencies": { + "typescript": ">=5.6", + "vite": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "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==", + "node_modules/@inlang/recommend-sherlock": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", + "integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" + "dependencies": { + "comment-json": "^4.2.3" } }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "node_modules/@inlang/sdk": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-3.0.6.tgz", + "integrity": "sha512-JyUxQNjKNWglT8qhInmenKK/AnKkbX9AhAM8vFnTT8SyQ0gFA2YGKPntGp+3+UZP9F06Nmawf4sFTn7nGIyTXQ==", "dev": true, "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "dependencies": { + "@lix-js/sdk": "0.17.0", + "@sinclair/typebox": "^0.31.17", + "kysely": "^0.28.12", + "uuid": "^14.0.0" }, - "funding": { - "url": "https://opencollective.com/pkgr" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", - "dev": true, + "node_modules/@internationalized/date": { + "version": "3.12.4", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz", + "integrity": "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" - }, - "bin": { - "playwright": "cli.js" + "@swc/helpers": "^0.5.0" + } + }, + "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==", + "dev": true, + "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" }, "engines": { - "node": ">=18" + "node": ">=12" } }, - "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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@poppinss/macroable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.1.0.tgz", - "integrity": "sha512-y/YKzZDuG8XrpXpM7Z1RdQpiIc0MAKyva24Ux1PB4aI7RiSI/79K8JVDcdyubriTm7vJ1LhFs8CrZpmPnx/8Pw==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "devOptional": true, "license": "MIT", - "optional": true + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } }, - "node_modules/@rollup/plugin-commonjs": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", - "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", - "dev": true, + "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==", + "devOptional": true, "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, + "@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==", + "devOptional": true, + "license": "MIT", "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "node": ">=6.0.0" } }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "dev": true, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "devOptional": true, "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", - "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", "dev": true, "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "debug": "^4.1.1" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.6.tgz", - "integrity": "sha512-G6xF9OVRWsbHadMfoPNUDwW/Jt70QYG+ZDUvtYCFxjf3SPhqVAO0u9ShXZ7Cf5silVoRWyN4bDjCEKnJkXkw0w==", - "cpu": [ - "arm" - ], + "node_modules/@lix-js/sdk": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.17.0.tgz", + "integrity": "sha512-xplbKYEokKbm0/soUy9eXlROF55Si1J9HK97iwJrNl/av1VixZ2mvpDTa0QsX96UyxFnzkXru6hngjtIJTl73g==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@bytecodealliance/jco-transpile": "0.13.0", + "binaryen": "130.0.0", + "fflate": "^0.8.3" + }, + "optionalDependencies": { + "@lix-js/sdk-darwin-arm64": "0.17.0", + "@lix-js/sdk-linux-arm64": "0.17.0", + "@lix-js/sdk-linux-x64": "0.17.0", + "@lix-js/sdk-win32-x64": "0.17.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.6.tgz", - "integrity": "sha512-Us/kTH5e2anr1CQvO8MEq4eeCVcIXI3Ag7OFxDI30n2OW7Q7xWuxo8ItbYq28PG2Sm1NWmZzG9+SqVEaj25SFA==", + "node_modules/@lix-js/sdk-darwin-arm64": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@lix-js/sdk-darwin-arm64/-/sdk-darwin-arm64-0.17.0.tgz", + "integrity": "sha512-+r74U7uBNfzgHEkxJAo4VkwQugqfk3MYZ5GHUIZnWZyyU1+oecXTmeepiaMnmHuflMPVv6ZfE4Yl8eM0Wyn+lw==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ - "android" + "darwin" ] }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.6.tgz", - "integrity": "sha512-fwaSNrSHp9PJuEh4bKtd0mYC+G3FeVDjSTmsSiSlIYu7TFCpxrfB6lk7Ao8cIbswFyklPB1ZDUV8LkLBM15VWw==", + "node_modules/@lix-js/sdk-linux-arm64": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@lix-js/sdk-linux-arm64/-/sdk-linux-arm64-0.17.0.tgz", + "integrity": "sha512-4HqHHsWq1gfPgkReR43Sy18T10JmRSo+VVR3Kqr/xu5zD/2ssoGdtl9QEpC80KVn4pCT5vDCysT1UOwEJkv7iA==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ - "darwin" + "linux" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.6.tgz", - "integrity": "sha512-QR2tx26gCeGh4eAGnUasfnJuRc+d7Vc0Jo1/fYtKGuiKqCuhbVpbNEvqgYGBwGDZGyS04jj+zdHi9W8uvb1Sdw==", + "node_modules/@lix-js/sdk-linux-x64": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@lix-js/sdk-linux-x64/-/sdk-linux-x64-0.17.0.tgz", + "integrity": "sha512-968kHRX0waV5UoR4i0HZkqNr5KDOWPd4he3miLImCMGsQDWLViYde613Bf4hxas/SouCSfmnvlAnl87oSAZc8w==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ - "darwin" + "linux" ] }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.6.tgz", - "integrity": "sha512-7E1wRJEW8r7WlqmCy58ugndWHrzoEV/pRt2x0D9el7Inra62+Cw00lYW9XhW6iUCtFjhNw49McsSCIzJ/nG8uw==", + "node_modules/@lix-js/sdk-win32-x64": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@lix-js/sdk-win32-x64/-/sdk-win32-x64-0.17.0.tgz", + "integrity": "sha512-/uYvg0c5R9+BtQ1aJqBE9VCwNSYSaZORUjo5u6ukAFkH7XuN1ZnpTzoI9K6HE82/dxlCymMsP3Ga/rQVpQn4hA==", "cpu": [ - "arm64" + "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ - "freebsd" + "win32" ] }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.6.tgz", - "integrity": "sha512-13KJeF+vDswMzEHmMIouCVcEQdov3CzAA3ZhM8QFmBJV/FZ/VMZI49UHDvPXTK+wokk86kYPmQ9Qb3xGGJbZoQ==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.6.tgz", - "integrity": "sha512-hV+W1r8HM84PER4If94QuzrrD0DNk4CxobNhVUiz9ZiiWHDzJss4Jra2AYGH1a7GOTzjS1sIyZ35yYEhw/aq+g==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" + "linux" ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.6.tgz", - "integrity": "sha512-5dWs/GENZufRph8PEfl0TrNH/R3DHJA+VxF/dqlfV2oUZfxRtN3qmFXg2DZj8DYM8d+Vd3dgZtuVmoBE/nb2YQ==", + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@oozcitak/dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", + "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/infra": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", + "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/url": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", + "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/util": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", + "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@oxc-minify/binding-android-arm-eabi": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.136.0.tgz", + "integrity": "sha512-gk1jzky1vLeOcK7w9Ib2RT/UooZ7O21s8YSeXCsy4tzsmguKb3Cbsv/Ra2fKhMp4mKeKCzvC1Xa41OfSBWfWdQ==", "cpu": [ "arm" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.6.tgz", - "integrity": "sha512-inQYLPVIUvYk+s7zpxQldqwrajUNs+t4TJjPGzsdPTvsT8gsQXVcW/sQFolHZ3YSWVk0nMMwU7TtRi0Ow+QMuw==", + "node_modules/@oxc-minify/binding-android-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.136.0.tgz", + "integrity": "sha512-5GIYanqud/ohXEcg2dsdn0+eSKXqclw5qlRUkvSM1sKEa4ZQ7hqOk8XPJBNPh3LXXw+Qw85B6jkmOFLKsCHp5w==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.6.tgz", - "integrity": "sha512-sH+0MV1HmDC1q7+bQnlN9ywKBmHqkA2EdD/hHYrrz654ybc+vNC1FRNyzuYKicMQVtZZ4X2jz0osJYmsOtnj4g==", + "node_modules/@oxc-minify/binding-darwin-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-darwin-arm64/-/binding-darwin-arm64-0.136.0.tgz", + "integrity": "sha512-QAMpHKXWOoMCTenSZjQjVT5YmMooujlCKAf1T3PHFl2LuaA6ukQFXba0bgSLgAxRrAeX/itkwi4lzVQXtYmnRg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.6.tgz", - "integrity": "sha512-mCECvRr6HGekdBc6dvHzUWubqEti4JyOqlNePrOzl4Wimk26iW5Zy9rld2mQbC9id1NYjfF8nHLFq5fr3CuOKQ==", + "node_modules/@oxc-minify/binding-darwin-x64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-darwin-x64/-/binding-darwin-x64-0.136.0.tgz", + "integrity": "sha512-rDnoPj8hztiU8C1VNKuzujh7SgTJlgiUvUCiWQjTnVSLsABsLW9ZWd1l1Ji0VN3UW39wGpptgvahIWTrHnGH/A==", "cpu": [ - "loong64" - ], - "libc": [ - "glibc" + "x64" ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-freebsd-x64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-freebsd-x64/-/binding-freebsd-x64-0.136.0.tgz", + "integrity": "sha512-9fGSgP7uyPLCTjMx9z3AUV4UjnfOVoGTH5JzamLegPwGLJ97Gr1jht1437CRk8ZNabGmxQR6qNn3e+Tox2lswg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm-gnueabihf": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.136.0.tgz", + "integrity": "sha512-k0QvZS+x6ROI4O5Ikqn9dDU2dmkEJ46Yh6baZJI/eUk/a1k1GOs9/vyXItLZrKlwYrSgdEx2BFXbKgyd1NyRPw==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.6.tgz", - "integrity": "sha512-bb9rdoCPwM4DEsbOP3CJgFnvhSJ3XKCUYyUhxnlSs/XGT0DknpOYgLCPbK8VUzwl+cZGzFQm0KbBYRaSGZC2YQ==", + "node_modules/@oxc-minify/binding-linux-arm-musleabihf": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.136.0.tgz", + "integrity": "sha512-iRFxUc8ubotVfuyq3e4LPGbHrjvWd1/B3499a55GueMLc1EDJ0fHzNXJiCx2eMQGfzHtHodjmvrJ2BSvSXBNhQ==", "cpu": [ - "loong64" + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.136.0.tgz", + "integrity": "sha512-hbKxNCXJsULrb6EyG5SumzJKuPfP8Y0UQ/TElRgXVUhyyPf6cBPlF8HMq+MAylF98ACBzV8jBp2Mm62lJyTo+w==", + "cpu": [ + "arm64" ], + "dev": true, "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.6.tgz", - "integrity": "sha512-aIJRoGCHev45JSu7JMzVrMzu1NX3nQrXx1Vb/6y4AblxW7JpCMpk4e8C6lHzvSElxi3UEcMdFJldsuWQjV1ddA==", + "node_modules/@oxc-minify/binding-linux-arm64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.136.0.tgz", + "integrity": "sha512-T+u/CGKW0UXemJeVux4ix6rK+ug3bQCUEbu5hFlX9K61KQNd44rOcrM15mDK99L7USaXwmiwHeIkEDvmmyDMSA==", "cpu": [ - "ppc64" + "arm64" ], + "dev": true, "libc": [ - "glibc" + "musl" ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.6.tgz", - "integrity": "sha512-LvjnulezHjiaM5Ga9MPCDLTVddjvcrJhZc6vEz9fxeFRGPJsPAX9HuTuPTUQ3l8d+nYpZB/xWzUpuKJlLQm3VQ==", + "node_modules/@oxc-minify/binding-linux-ppc64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.136.0.tgz", + "integrity": "sha512-WNPTN2F448tb49BoqjV9IMRzZWa0WB8GHzrB+bnxKQPOyzz07T6rmDKrr4Hjl81ADGjDYSsr1fgXcjyLQFdMdw==", "cpu": [ "ppc64" ], + "dev": true, "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.6.tgz", - "integrity": "sha512-YmhSBeYZwJ937s8tKpYAhms48N+tj7e+oFkbkVVxfMRdAiynK2AxqRapJXB4utyXxc/iYTkMfPRQ+7ZP0IO78w==", + "node_modules/@oxc-minify/binding-linux-riscv64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.136.0.tgz", + "integrity": "sha512-MvcCfu8OeabrmNN9wqhh8Gqizg68RQ3iOvXBJmlLMy5p1PC6az9bAwqOk98lsCmKI4I5LpULekOlfxRYh3o5Kw==", "cpu": [ "riscv64" ], + "dev": true, "libc": [ "glibc" ], @@ -2554,15 +2872,19 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.6.tgz", - "integrity": "sha512-9+YukhzqTvJvzfDXJJzkURK9TDY01nofNH3pyHGuucGC4xPvBVtIfCAqJjkjnmBEGJYNXB8sBc2EDt7xDgGenw==", + "node_modules/@oxc-minify/binding-linux-riscv64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.136.0.tgz", + "integrity": "sha512-rwJXFUFFA0egAULtOOgfW8YhhLhtWrr2qFxftvZ7/U6Xtxiev8i+eCwSNW1RpHiX74QMsJVpRE3ykoefqb1MtQ==", "cpu": [ "riscv64" ], + "dev": true, "libc": [ "musl" ], @@ -2570,15 +2892,19 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.6.tgz", - "integrity": "sha512-9G5AtEkpN/A89BILHHEL9aYRYTuFjwa8PUQqoEg8SDrwOZBKpwYqNlQCBNI5QPwGrf7qFlb6LNFilo8FCSA3Wg==", + "node_modules/@oxc-minify/binding-linux-s390x-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.136.0.tgz", + "integrity": "sha512-qizAxHmj7VHm5/D5qiIlvzxAS2tlmadDOJ5XFI7zx/wfnk/+fdk5iVaHOv+C4tDE/4wLQyMoBYVIs6R1LeN91w==", "cpu": [ "s390x" ], + "dev": true, "libc": [ "glibc" ], @@ -2586,15 +2912,19 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.6.tgz", - "integrity": "sha512-Ezx2E5D6Siz805j41x91JiGbzuCm8i2U98U37SQ7ytY737+wsJq+P9nPL6UmqDww491zO7MTivYOTrtmTTeCSw==", + "node_modules/@oxc-minify/binding-linux-x64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.136.0.tgz", + "integrity": "sha512-gcyLAad0UblkEhGjVfx5Mwq63OSx8r04IbcfwzpZYpHisO/vioMsrs4GkDsQZ2Ejc8cdUk6YMIrf9FwNBzh+Dw==", "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -2602,15 +2932,19 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.6.tgz", - "integrity": "sha512-8EZ1Q3PB7yUjvR4MbFAnCBF1wF0XRe5QQqbtlJXP7ZX5C8iYlSQJugUhj9oS0gbUP4jp9qGhaIgFnV9Nvod86Q==", + "node_modules/@oxc-minify/binding-linux-x64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-x64-musl/-/binding-linux-x64-musl-0.136.0.tgz", + "integrity": "sha512-QAK6kwBjljX6luYzeppVdRP3gTaFNFfp9iS/V0cCvU43wUZay5xFXBgmzBHYrzZV2MhzURNnXfEZRo9poAxyvA==", "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -2618,3838 +2952,7441 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.6.tgz", - "integrity": "sha512-3JI27TALItfZ/qaxXKdbRXfV6WepUoUa3IN07FE+1xxoRdl3JIyrp5PiDF0vOHsPgEiOFkF8s3B3BRymQkdwGA==", + "node_modules/@oxc-minify/binding-openharmony-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-openharmony-arm64/-/binding-openharmony-arm64-0.136.0.tgz", + "integrity": "sha512-nIGxkTOnN9GwslmooxbZ3Ti2RgbX9QLHy0X9uO0pslOT+4dsNVUawBU/6r4M5cWM+ZdRD2tOF4V+n0l8tS59nQ==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.6.tgz", - "integrity": "sha512-BNOGeNFNRZDT7Hfc7b9JbTZFXkpAXeXl/CZWtxdd5CNfBaor/0RkJjg+q+E2gwVIvGqqp/NclThKFGrhuScVhw==", + "node_modules/@oxc-minify/binding-wasm32-wasi": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-wasm32-wasi/-/binding-wasm32-wasi-0.136.0.tgz", + "integrity": "sha512-YB3G8AHT6PpDS8/DfjlJrQjywj+/6gCkNBcAJdYzcYbQRIZE6SaTNowiGuZPR1m1jOl1WjcjefniKSmVxzhvRA==", "cpu": [ - "arm64" + "wasm32" ], + "dev": true, "license": "MIT", "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.6.tgz", - "integrity": "sha512-D3eEyLSIim5WF5fHbwnx+ryk0delN7fJyBrDTO3wVCCNBZ/cA8aCGwLS3SiuK+vRI4E7dqikAlStGuR20dBMuw==", + "node_modules/@oxc-minify/binding-win32-arm64-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.136.0.tgz", + "integrity": "sha512-UVRH3BlNc2rmla+sbSd5xTF/u+esCqZS6VSWPolZybcBh3TlVe1Sf5MOuHQWUaFoyv5uFOvee4hOfS/14nPvZw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.6.tgz", - "integrity": "sha512-xwbs9g8zLbHzj1poG1uh27xtubsu27Gk2nuN6kvvGDybjiTfwMksQAnF+phlvI5oeXkJWWDc6kg1t3XpgUYvrg==", + "node_modules/@oxc-minify/binding-win32-ia32-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.136.0.tgz", + "integrity": "sha512-zFGkUd82jci0E+isc2O36lVmmXVUDXyEMN2RYIyez+MBNqhdjPLIGx62FCsrJtLKeuiPf/8XkFv977aUtndUng==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.6.tgz", - "integrity": "sha512-Tr6rhRNnFthvXr45zWVZQ5P8jZ5UwZnXNc7Qnu80cP/VVYtSioOPmvv7wP6UMgRfkBCJr2Pm60ua0YBDSJjgnw==", + "node_modules/@oxc-minify/binding-win32-x64-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.136.0.tgz", + "integrity": "sha512-VHgPwd5xibC9UYxBeq7nURTjY+wxhdumllij6JaCIUVfD9IRcrA66SVlJ5fPNoUXcRp8ZXhJfoIZwVz9wAEGJw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.6.tgz", - "integrity": "sha512-0RAxO1pS/6lY1mSoD2Apm+6aPyfUlm+OWKgncIdj/eVN9I+pDY3lKgD7xsUNFei1u7MZQETGOo6m8POXMDp53A==", - "cpu": [ - "x64" ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "@hapi/hoek": "^9.0.0" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@sinclair/typebox": { - "version": "0.31.30", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.30.tgz", - "integrity": "sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==", - "dev": true, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, - "node_modules/@smithy/core": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", - "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", - "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.33.2", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, "engines": { - "node": ">=18.0.0" + "node": ">=14" } }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", - "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.18.0", - "tslib": "^2.6.2" - }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.1.tgz", - "integrity": "sha512-m/f15di58P6NtLQ7eVEb5N19NdJWn+4c7zfkFHMT/i3JH7U8UtknpPoy8o2tm2R3OdliYvsvQhZHIfACQDqT+Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.28.0", - "@smithy/types": "^4.15.0", - "tslib": "^2.6.2" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/pkgr" } }, - "node_modules/@smithy/signature-v4": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", - "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" + "playwright": "1.61.1" }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", - "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "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==", + "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==", + "devOptional": true, "license": "MIT" }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", - "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", - "devOptional": true, + "node_modules/@poppinss/macroable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.1.0.tgz", + "integrity": "sha512-y/YKzZDuG8XrpXpM7Z1RdQpiIc0MAKyva24Ux1PB4aI7RiSI/79K8JVDcdyubriTm7vJ1LhFs8CrZpmPnx/8Pw==", + "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } + "optional": true }, - "node_modules/@sveltejs/adapter-node": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.3.tgz", - "integrity": "sha512-yeWbKXBL9vqDb/7R8ebvRHeuBHN4cRYYBSquNJSMQtS6rIYkXxsVSveaMTUaLvHYQsb1zNa+nH2iLTOMawBohA==", + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.0", - "rollup": "^4.9.5" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.4.0" + "@protobufjs/aspromise": "^1.1.1" } }, - "node_modules/@sveltejs/kit": { - "version": "2.70.3", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", - "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", - "devOptional": true, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", + "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", + "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" + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=18.13" + "node": ">=16.0.0 || 14 >= 14.17" }, "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" + "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "typescript": { + "rollup": { "optional": true } } }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", - "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", - "devOptional": true, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", - "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", - "obug": "^2.1.0", - "vitefu": "^1.1.1" + "@rollup/pluginutils": "^5.1.0" }, "engines": { - "node": "^20.19 || ^22.12 || >=24" + "node": ">=14.0.0" }, "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", - "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", - "devOptional": true, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, "license": "MIT", "dependencies": { - "obug": "^2.1.0" + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" }, "engines": { - "node": "^20.19 || ^22.12 || >=24" + "node": ">=14.0.0" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.18", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", - "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.31.1", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", - "dev": true, - "license": "MIT", + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, "engines": { - "node": ">= 20" + "node": ">=14.0.0" }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.6.tgz", + "integrity": "sha512-G6xF9OVRWsbHadMfoPNUDwW/Jt70QYG+ZDUvtYCFxjf3SPhqVAO0u9ShXZ7Cf5silVoRWyN4bDjCEKnJkXkw0w==", "cpu": [ - "arm64" + "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.6.tgz", + "integrity": "sha512-Us/kTH5e2anr1CQvO8MEq4eeCVcIXI3Ag7OFxDI30n2OW7Q7xWuxo8ItbYq28PG2Sm1NWmZzG9+SqVEaj25SFA==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">= 20" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.6.tgz", + "integrity": "sha512-fwaSNrSHp9PJuEh4bKtd0mYC+G3FeVDjSTmsSiSlIYu7TFCpxrfB6lk7Ao8cIbswFyklPB1ZDUV8LkLBM15VWw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ], - "engines": { - "node": ">= 20" - } + ] }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.6.tgz", + "integrity": "sha512-QR2tx26gCeGh4eAGnUasfnJuRc+d7Vc0Jo1/fYtKGuiKqCuhbVpbNEvqgYGBwGDZGyS04jj+zdHi9W8uvb1Sdw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ], - "engines": { - "node": ">= 20" - } + ] }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.6.tgz", + "integrity": "sha512-7E1wRJEW8r7WlqmCy58ugndWHrzoEV/pRt2x0D9el7Inra62+Cw00lYW9XhW6iUCtFjhNw49McsSCIzJ/nG8uw==", "cpu": [ - "x64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ], - "engines": { - "node": ">= 20" - } + ] }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.6.tgz", + "integrity": "sha512-13KJeF+vDswMzEHmMIouCVcEQdov3CzAA3ZhM8QFmBJV/FZ/VMZI49UHDvPXTK+wokk86kYPmQ9Qb3xGGJbZoQ==", "cpu": [ - "arm" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } + "freebsd" + ] }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.6.tgz", + "integrity": "sha512-hV+W1r8HM84PER4If94QuzrrD0DNk4CxobNhVUiz9ZiiWHDzJss4Jra2AYGH1a7GOTzjS1sIyZ35yYEhw/aq+g==", "cpu": [ - "arm64" + "arm" + ], + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 20" - } + ] }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.6.tgz", + "integrity": "sha512-5dWs/GENZufRph8PEfl0TrNH/R3DHJA+VxF/dqlfV2oUZfxRtN3qmFXg2DZj8DYM8d+Vd3dgZtuVmoBE/nb2YQ==", "cpu": [ - "arm64" + "arm" + ], + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 20" - } + ] }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.6.tgz", + "integrity": "sha512-inQYLPVIUvYk+s7zpxQldqwrajUNs+t4TJjPGzsdPTvsT8gsQXVcW/sQFolHZ3YSWVk0nMMwU7TtRi0Ow+QMuw==", "cpu": [ - "x64" + "arm64" + ], + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 20" - } + ] }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.6.tgz", + "integrity": "sha512-sH+0MV1HmDC1q7+bQnlN9ywKBmHqkA2EdD/hHYrrz654ybc+vNC1FRNyzuYKicMQVtZZ4X2jz0osJYmsOtnj4g==", "cpu": [ - "x64" + "arm64" + ], + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 20" - } + ] }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.6.tgz", + "integrity": "sha512-mCECvRr6HGekdBc6dvHzUWubqEti4JyOqlNePrOzl4Wimk26iW5Zy9rld2mQbC9id1NYjfF8nHLFq5fr3CuOKQ==", + "cpu": [ + "loong64" ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.6.tgz", + "integrity": "sha512-bb9rdoCPwM4DEsbOP3CJgFnvhSJ3XKCUYyUhxnlSs/XGT0DknpOYgLCPbK8VUzwl+cZGzFQm0KbBYRaSGZC2YQ==", "cpu": [ - "wasm32" + "loong64" + ], + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } + "os": [ + "linux" + ] }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.6.tgz", + "integrity": "sha512-aIJRoGCHev45JSu7JMzVrMzu1NX3nQrXx1Vb/6y4AblxW7JpCMpk4e8C6lHzvSElxi3UEcMdFJldsuWQjV1ddA==", "cpu": [ - "arm64" + "ppc64" + ], + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.6.tgz", + "integrity": "sha512-LvjnulezHjiaM5Ga9MPCDLTVddjvcrJhZc6vEz9fxeFRGPJsPAX9HuTuPTUQ3l8d+nYpZB/xWzUpuKJlLQm3VQ==", + "cpu": [ + "ppc64" ], - "engines": { - "node": ">= 20" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.6.tgz", + "integrity": "sha512-YmhSBeYZwJ937s8tKpYAhms48N+tj7e+oFkbkVVxfMRdAiynK2AxqRapJXB4utyXxc/iYTkMfPRQ+7ZP0IO78w==", "cpu": [ - "x64" + "riscv64" + ], + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.6.tgz", + "integrity": "sha512-9+YukhzqTvJvzfDXJJzkURK9TDY01nofNH3pyHGuucGC4xPvBVtIfCAqJjkjnmBEGJYNXB8sBc2EDt7xDgGenw==", + "cpu": [ + "riscv64" ], - "engines": { - "node": ">= 20" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", - "dev": true, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.6.tgz", + "integrity": "sha512-9G5AtEkpN/A89BILHHEL9aYRYTuFjwa8PUQqoEg8SDrwOZBKpwYqNlQCBNI5QPwGrf7qFlb6LNFilo8FCSA3Wg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@testing-library/svelte-core": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", - "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", - "dev": true, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.6.tgz", + "integrity": "sha512-Ezx2E5D6Siz805j41x91JiGbzuCm8i2U98U37SQ7ytY737+wsJq+P9nPL6UmqDww491zO7MTivYOTrtmTTeCSw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", - "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", - "dev": true, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.6.tgz", + "integrity": "sha512-8EZ1Q3PB7yUjvR4MbFAnCBF1wF0XRe5QQqbtlJXP7ZX5C8iYlSQJugUhj9oS0gbUP4jp9qGhaIgFnV9Nvod86Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "os": [ + "linux" + ] }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "devOptional": true, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.6.tgz", + "integrity": "sha512-3JI27TALItfZ/qaxXKdbRXfV6WepUoUa3IN07FE+1xxoRdl3JIyrp5PiDF0vOHsPgEiOFkF8s3B3BRymQkdwGA==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "devOptional": true, - "license": "MIT" + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.6.tgz", + "integrity": "sha512-BNOGeNFNRZDT7Hfc7b9JbTZFXkpAXeXl/CZWtxdd5CNfBaor/0RkJjg+q+E2gwVIvGqqp/NclThKFGrhuScVhw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "devOptional": true, - "license": "MIT" + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.6.tgz", + "integrity": "sha512-D3eEyLSIim5WF5fHbwnx+ryk0delN7fJyBrDTO3wVCCNBZ/cA8aCGwLS3SiuK+vRI4E7dqikAlStGuR20dBMuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "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==", - "devOptional": true, - "license": "MIT" + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.6.tgz", + "integrity": "sha512-xwbs9g8zLbHzj1poG1uh27xtubsu27Gk2nuN6kvvGDybjiTfwMksQAnF+phlvI5oeXkJWWDc6kg1t3XpgUYvrg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "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==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.6.tgz", + "integrity": "sha512-Tr6rhRNnFthvXr45zWVZQ5P8jZ5UwZnXNc7Qnu80cP/VVYtSioOPmvv7wP6UMgRfkBCJr2Pm60ua0YBDSJjgnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.6.tgz", + "integrity": "sha512-0RAxO1pS/6lY1mSoD2Apm+6aPyfUlm+OWKgncIdj/eVN9I+pDY3lKgD7xsUNFei1u7MZQETGOo6m8POXMDp53A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "24.10.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", - "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "devOptional": true, - "license": "MIT", + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, "dependencies": { - "undici-types": "~7.16.0" + "@hapi/hoek": "^9.0.0" } }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", "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", + "license": "BSD-3-Clause", "optional": true }, - "node_modules/@types/validator": { - "version": "13.15.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", - "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "optional": true }, - "node_modules/@typeschema/class-validator": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@typeschema/class-validator/-/class-validator-0.3.0.tgz", - "integrity": "sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==", + "node_modules/@sinclair/typebox": { + "version": "0.31.30", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.30.tgz", + "integrity": "sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==", "dev": true, - "license": "MIT", - "optional": true, + "license": "MIT" + }, + "node_modules/@smithy/core": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", + "license": "Apache-2.0", "dependencies": { - "@typeschema/core": "0.14.0" - }, - "peerDependencies": { - "class-validator": "^0.14.1" + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" }, - "peerDependenciesMeta": { - "class-validator": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@typeschema/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@typeschema/core/-/core-0.14.0.tgz", - "integrity": "sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==", - "dev": true, - "license": "MIT", - "optional": true, - "peerDependencies": { - "@types/json-schema": "^7.0.15" + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, - "peerDependenciesMeta": { - "@types/json-schema": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", - "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/type-utils": "8.71.0", - "@typescript-eslint/utils": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.71.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz", - "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/node-http-handler": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.1.tgz", + "integrity": "sha512-m/f15di58P6NtLQ7eVEb5N19NdJWn+4c7zfkFHMT/i3JH7U8UtknpPoy8o2tm2R3OdliYvsvQhZHIfACQDqT+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">= 4" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", - "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", - "debug": "^4.4.3" + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", - "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.0", - "@typescript-eslint/types": "^8.71.0", - "debug": "^4.4.3" + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", - "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", - "dev": true, + "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==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "devOptional": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "acorn": "^8.9.0" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", - "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.3.tgz", + "integrity": "sha512-yeWbKXBL9vqDb/7R8ebvRHeuBHN4cRYYBSquNJSMQtS6rIYkXxsVSveaMTUaLvHYQsb1zNa+nH2iLTOMawBohA==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.9.5" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "@sveltejs/kit": "^2.4.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", - "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", - "dev": true, + "node_modules/@sveltejs/kit": { + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/utils": "8.71.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" + "@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" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "bin": { + "svelte-kit": "svelte-kit.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "engines": { + "node": ">=18.13" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "@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": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } } }, - "node_modules/@typescript-eslint/types": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", - "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "devOptional": true, "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19 || ^22.12 || >=24" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", - "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", - "dev": true, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.0", - "@typescript-eslint/tsconfig-utils": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" + "obug": "^2.1.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/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", - "engines": { - "node": "18 || 20 || >=22" + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">= 20" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", - "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">= 20" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", - "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", - "dev": true, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.71.0", - "eslint-visitor-keys": "^5.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">= 20" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 20" } }, - "node_modules/@valibot/to-json-schema": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.8.0.tgz", - "integrity": "sha512-a0M+uwCuQZEPAo65NYkFSJ14O5c213KoSZmPdoCfuFvUhfULi4T2Z6Tpjv6lTVMW9RoLm74RpRAjNEa43KcN+g==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "peerDependencies": { - "valibot": "^1.5.0" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@vinejs/compiler": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-3.0.0.tgz", - "integrity": "sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": ">= 20" } }, - "node_modules/@vinejs/vine": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz", - "integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@poppinss/macroable": "^1.0.4", - "@types/validator": "^13.12.2", - "@vinejs/compiler": "^3.0.0", - "camelcase": "^8.0.0", - "dayjs": "^1.11.13", - "dlv": "^1.1.3", - "normalize-url": "^8.0.1", - "validator": "^13.12.0" - }, + "os": [ + "linux" + ], "engines": { - "node": ">=18.16.0" + "node": ">= 20" } }, - "node_modules/@vitest/browser": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", - "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", - "devOptional": true, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pngjs": "^7.0.0", - "sirv": "^3.0.2", - "tinyrainbow": "^3.1.0", - "ws": "^8.19.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.1.11" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@vitest/browser-playwright": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", - "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", - "devOptional": true, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@vitest/browser": "4.1.11", - "@vitest/mocker": "4.1.11", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "4.1.11" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": false - } + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", - "devOptional": true, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "devOptional": true, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", - "devOptional": true, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "vite": "^5.2.0 || ^6 || ^7" } }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "devOptional": true, + "node_modules/@testcontainers/postgresql": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-12.1.0.tgz", + "integrity": "sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" + "testcontainers": "^12.1.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "devOptional": true, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" + "engines": { + "node": ">=16" }, - "funding": { - "url": "https://opencollective.com/vitest" + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "devOptional": true, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "tslib": "^2.4.0" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "devOptional": true, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@types/node": "*" } }, - "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "devOptional": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@types/node": "*", + "@types/ssh2": "*" } }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "node_modules/@types/dockerode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", "dev": true, - "license": "ISC", - "optional": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "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==", + "devOptional": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "devOptional": true, "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" + "undici-types": "~7.16.0" } }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", "devOptional": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ssh2": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.6.tgz", + "integrity": "sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==", "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "@types/node": "^18.11.18" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/@types/ssh2-streams": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz", + "integrity": "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14" + "dependencies": { + "@types/node": "*" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "undici-types": "~5.26.4" } }, - "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/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tar-stream": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.4.tgz", + "integrity": "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "ajv": "^8.0.0" + "@types/node": "*" + } + }, + "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", + "optional": true + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@typeschema/class-validator": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@typeschema/class-validator/-/class-validator-0.3.0.tgz", + "integrity": "sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@typeschema/core": "0.14.0" }, "peerDependencies": { - "ajv": "^8.0.0" + "class-validator": "^0.14.1" }, "peerDependenciesMeta": { - "ajv": { + "class-validator": { "optional": true } } }, - "node_modules/ajv-formats-draft2019": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.1.tgz", - "integrity": "sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==", + "node_modules/@typeschema/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@typeschema/core/-/core-0.14.0.tgz", + "integrity": "sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==", "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "punycode": "^2.1.1", - "schemes": "^1.4.0", - "smtp-address-parser": "^1.0.3", - "uri-js": "^4.4.1" - }, "peerDependencies": { - "ajv": "*" + "@types/json-schema": "^7.0.15" + }, + "peerDependenciesMeta": { + "@types/json-schema": { + "optional": true + } } }, - "node_modules/ajv-formats/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==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ajv-formats/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==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz", + "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==", "dev": true, "license": "MIT", - "optional": true + "engines": { + "node": ">= 4" + } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "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/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/antlr-ng": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/antlr-ng/-/antlr-ng-1.0.10.tgz", - "integrity": "sha512-fw3NdsQP3dabuZrDhKAMewrBsY5KSAcMrvhWBVDmHYegv5D51pypzCYK1PpjaRVKcVeP/5xKfqJY31TvXACOdA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "antlr4ng": "3.0.16", - "commander": "13.1.0", - "stringtemplate4ts": "1.0.9", - "unicode-properties": "1.4.1" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, - "bin": { - "antlr-ng": "dist/cli/runner.js", - "grun": "dist/cli/TestRig.js", - "interpreter": "dist/cli/Interpreter.js", - "testrig": "dist/cli/TestRig.js" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/antlr-ng/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/antlr4-c3": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/antlr4-c3/-/antlr4-c3-3.4.4.tgz", - "integrity": "sha512-ixp1i17ypbRzZnffdarIfCVEXJwPydtDt61SHMGkc+UCD7rrbfvHESTMTgx8jFhUgKAgcHyt9060kQ8nU3vlxA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, "license": "MIT", "dependencies": { - "antlr4ng": "3.0.16" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/antlr4ng": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/antlr4ng/-/antlr4ng-3.0.16.tgz", - "integrity": "sha512-DQuJkC7kX3xunfF4K2KsWTSvoxxslv+FQp/WHQZTJSsH2Ec3QfFmrxC3Nky2ok9yglXn6nHM4zUaVDxcN5f6kA==", - "license": "BSD-3-Clause" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/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==", + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "devOptional": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/arkregex": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.12.tgz", - "integrity": "sha512-sOxL0eX50pnRYO4OyajKZChGuuj/iHwC5tysiAaGOPBu+zJwJQckWn7/DRH2++ol5uaKpyRn7N0hKT7nDqGmgg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@ark/util": "0.56.6" - } - }, - "node_modules/arktype": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.7.tgz", - "integrity": "sha512-AbgM5SQvwCTURSR+ENfPtOG7cL5VZ0hqOVtoWyXKw2SviFddZhCBEUAjjVJYKCNFlPPR1VX5LGUKxsSqqc4Kdw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@ark/schema": "0.56.6", - "@ark/util": "0.56.6", - "arkregex": "0.0.12" + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "node_modules/@typescript-eslint/typescript-estree/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" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "devOptional": true, "license": "MIT", "engines": { - "node": ">=12" + "node": "18 || 20 || >=22" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", - "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", - "devOptional": true, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, - "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "devOptional": true, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, "engines": { - "node": ">=8.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "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==", - "devOptional": true, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, "license": "Apache-2.0", "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "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/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "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==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" ], - "license": "MIT" + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/better-auth": { - "version": "1.6.33", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.33.tgz", - "integrity": "sha512-QkVKOmJw/LQ3HjIib2wx2Td0xB9EuPgw0ARYPKeFu3niN0VSBW32j4QwfewFZOQi5Hb/jOB6U+wdvtBhF7+URw==", + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@better-auth/core": "1.6.33", - "@better-auth/drizzle-adapter": "1.6.33", - "@better-auth/kysely-adapter": "1.6.33", - "@better-auth/memory-adapter": "1.6.33", - "@better-auth/mongo-adapter": "1.6.33", - "@better-auth/prisma-adapter": "1.6.33", - "@better-auth/telemetry": "1.6.33", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", - "@noble/ciphers": "^2.1.1", - "@noble/hashes": "^2.0.1", - "better-call": "1.4.0", - "defu": "^6.1.4", - "jose": "^6.1.3", - "kysely": "^0.28.17 || ^0.29.0", - "nanostores": "^1.1.1", - "zod": "^4.3.6" - }, - "peerDependencies": { - "@lynx-js/react": "*", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "@sveltejs/kit": "^2.0.0", - "@tanstack/react-start": "^1.0.0", - "@tanstack/solid-start": "^1.0.0", - "better-sqlite3": "^12.0.0", - "drizzle-kit": ">=0.31.4", - "drizzle-orm": "^0.45.2", - "mongodb": "^6.0.0 || ^7.0.0", - "mysql2": "^3.0.0", - "next": "^14.0.0 || ^15.0.0 || ^16.0.0", - "pg": "^8.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "solid-js": "^1.0.0", - "svelte": "^4.0.0 || ^5.0.0", - "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", - "vue": "^3.0.0" - }, - "peerDependenciesMeta": { - "@lynx-js/react": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@sveltejs/kit": { - "optional": true - }, - "@tanstack/react-start": { - "optional": true - }, - "@tanstack/solid-start": { - "optional": true - }, - "better-sqlite3": { + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/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/@unrs/resolver-binding-wasm32-wasi/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_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@valibot/to-json-schema": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.8.0.tgz", + "integrity": "sha512-a0M+uwCuQZEPAo65NYkFSJ14O5c213KoSZmPdoCfuFvUhfULi4T2Z6Tpjv6lTVMW9RoLm74RpRAjNEa43KcN+g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "valibot": "^1.5.0" + } + }, + "node_modules/@vinejs/compiler": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-3.0.0.tgz", + "integrity": "sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@vinejs/vine": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz", + "integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@poppinss/macroable": "^1.0.4", + "@types/validator": "^13.12.2", + "@vinejs/compiler": "^3.0.0", + "camelcase": "^8.0.0", + "dayjs": "^1.11.13", + "dlv": "^1.1.3", + "normalize-url": "^8.0.1", + "validator": "^13.12.0" + }, + "engines": { + "node": ">=18.16.0" + } + }, + "node_modules/@vitest/browser": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", + "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.11" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", + "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.11", + "@vitest/mocker": "4.1.11", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@zerollup/ts-helpers": { + "version": "1.7.18", + "resolved": "https://registry.npmjs.org/@zerollup/ts-helpers/-/ts-helpers-1.7.18.tgz", + "integrity": "sha512-S9zN+y+i5yN/evfWquzSO3lubqPXIsPQf6p9OiPMpRxDx/0totPLF39XoRw48Dav5dSvbIE8D2eAPpXXJxvKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.12.0" + }, + "peerDependencies": { + "typescript": ">=3.7.2" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adm-zip": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", + "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", + "license": "MIT", + "engines": { + "node": ">=14.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats-draft2019": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.1.tgz", + "integrity": "sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.1.1", + "schemes": "^1.4.0", + "smtp-address-parser": "^1.0.3", + "uri-js": "^4.4.1" + }, + "peerDependencies": { + "ajv": "*" + } + }, + "node_modules/ajv-formats/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==", + "dev": true, + "license": "MIT", + "optional": true, + "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/ajv-formats/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==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "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": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antlr-ng": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/antlr-ng/-/antlr-ng-1.0.10.tgz", + "integrity": "sha512-fw3NdsQP3dabuZrDhKAMewrBsY5KSAcMrvhWBVDmHYegv5D51pypzCYK1PpjaRVKcVeP/5xKfqJY31TvXACOdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "antlr4ng": "3.0.16", + "commander": "13.1.0", + "stringtemplate4ts": "1.0.9", + "unicode-properties": "1.4.1" + }, + "bin": { + "antlr-ng": "dist/cli/runner.js", + "grun": "dist/cli/TestRig.js", + "interpreter": "dist/cli/Interpreter.js", + "testrig": "dist/cli/TestRig.js" + } + }, + "node_modules/antlr-ng/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/antlr4-c3": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/antlr4-c3/-/antlr4-c3-3.4.4.tgz", + "integrity": "sha512-ixp1i17ypbRzZnffdarIfCVEXJwPydtDt61SHMGkc+UCD7rrbfvHESTMTgx8jFhUgKAgcHyt9060kQ8nU3vlxA==", + "license": "MIT", + "dependencies": { + "antlr4ng": "3.0.16" + } + }, + "node_modules/antlr4ng": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/antlr4ng/-/antlr4ng-3.0.16.tgz", + "integrity": "sha512-DQuJkC7kX3xunfF4K2KsWTSvoxxslv+FQp/WHQZTJSsH2Ec3QfFmrxC3Nky2ok9yglXn6nHM4zUaVDxcN5f6kA==", + "license": "BSD-3-Clause" + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/archunit/-/archunit-2.5.4.tgz", + "integrity": "sha512-RiHvwHovO/Ey/sVSXsie/20Y+Ck0MDOqXKZwpRj1VY0A701tMnPwEIWTLFLeuNYpgEZ2DNbjRhWJqC3B5neXGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zerollup/ts-helpers": "^1.7.18", + "minimatch": "^10.0.1", + "plantuml-parser": "^0.4.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/archunit/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", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/archunit/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/archunit/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/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==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arkregex": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.12.tgz", + "integrity": "sha512-sOxL0eX50pnRYO4OyajKZChGuuj/iHwC5tysiAaGOPBu+zJwJQckWn7/DRH2++ol5uaKpyRn7N0hKT7nDqGmgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/util": "0.56.6" + } + }, + "node_modules/arktype": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.7.tgz", + "integrity": "sha512-AbgM5SQvwCTURSR+ENfPtOG7cL5VZ0hqOVtoWyXKw2SviFddZhCBEUAjjVJYKCNFlPPR1VX5LGUKxsSqqc4Kdw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/schema": "0.56.6", + "@ark/util": "0.56.6", + "arkregex": "0.0.12" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "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==", + "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/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/better-auth": { + "version": "1.6.33", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.33.tgz", + "integrity": "sha512-QkVKOmJw/LQ3HjIib2wx2Td0xB9EuPgw0ARYPKeFu3niN0VSBW32j4QwfewFZOQi5Hb/jOB6U+wdvtBhF7+URw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.33", + "@better-auth/drizzle-adapter": "1.6.33", + "@better-auth/kysely-adapter": "1.6.33", + "@better-auth/memory-adapter": "1.6.33", + "@better-auth/mongo-adapter": "1.6.33", + "@better-auth/prisma-adapter": "1.6.33", + "@better-auth/telemetry": "1.6.33", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.4.0", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/binaryen": { + "version": "130.0.0", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-130.0.0.tgz", + "integrity": "sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-as": "bin/wasm-as", + "wasm-ctor-eval": "bin/wasm-ctor-eval", + "wasm-dis": "bin/wasm-dis", + "wasm-merge": "bin/wasm-merge", + "wasm-metadce": "bin/wasm-metadce", + "wasm-opt": "bin/wasm-opt", + "wasm-reduce": "bin/wasm-reduce", + "wasm-shell": "bin/wasm-shell", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/comment-json": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.5.1.tgz", + "integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", + "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "dev": true, + "license": "MIT", + "optional": 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/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "devOptional": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/docker-compose": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.5.0.tgz", + "integrity": "sha512-O+eD6c63+BJORUWddXA7uAlI6H1KLDPd/aS14k73nXYpFliM48C3xrrKB3sXsR1Fp/rHUZQe7+fJ+QA7+jepyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4" + }, + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.11", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", + "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", + "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { "optional": true }, - "drizzle-kit": { + "@cloudflare/workers-types": { "optional": true }, - "drizzle-orm": { + "@electric-sql/pglite": { "optional": true }, - "mongodb": { + "@libsql/client": { "optional": true }, - "mysql2": { + "@libsql/client-wasm": { "optional": true }, - "next": { + "@neondatabase/serverless": { "optional": true }, - "pg": { + "@netlify/db": { "optional": true }, - "prisma": { + "@op-engineering/op-sqlite": { "optional": true }, - "react": { + "@opentelemetry/api": { "optional": true }, - "react-dom": { + "@planetscale/database": { "optional": true }, - "solid-js": { + "@prisma/client": { "optional": true }, - "svelte": { + "@tidbcloud/serverless": { "optional": true }, - "vitest": { + "@types/better-sqlite3": { "optional": true }, - "vue": { + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { "optional": true } } }, - "node_modules/better-call": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", - "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "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/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.22.2", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.22.2.tgz", + "integrity": "sha512-fm1CQXCs7GTMwpadZxwU5rnAb2bN6mbQpknq/pXBGCzSKkosNYb9k0wsoL0TfB10rSa5xOEZJjo77u/KLXSANg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/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, "dependencies": { - "@better-auth/utils": "^0.5.0", - "@better-fetch/fetch": "^1.3.1", - "rou3": "^0.9.1", - "set-cookie-parser": "^3.1.2" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, - "peerDependencies": { - "zod": "^4.0.0" + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } + "engines": { + "node": ">=10.13.0" + } + }, + "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", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "devOptional": true, + "license": "MIT" + }, + "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-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/better-call/node_modules/@better-auth/utils": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", - "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@noble/hashes": "^2.0.1" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/binaryen": { - "version": "130.0.0", - "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-130.0.0.tgz", - "integrity": "sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "wasm-as": "bin/wasm-as", - "wasm-ctor-eval": "bin/wasm-ctor-eval", - "wasm-dis": "bin/wasm-dis", - "wasm-merge": "bin/wasm-merge", - "wasm-metadce": "bin/wasm-metadce", - "wasm-opt": "bin/wasm-opt", - "wasm-reduce": "bin/wasm-reduce", - "wasm-shell": "bin/wasm-shell", - "wasm2js": "bin/wasm2js" + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], "license": "MIT", "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/bintrees": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", - "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], "license": "MIT", "optional": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "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" - } + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" ], "license": "MIT", "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=18" } }, - "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, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "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, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "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, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], "license": "MIT", "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "devOptional": true, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=18" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=18" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/class-validator": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", - "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], "license": "MIT", "optional": true, - "dependencies": { - "@types/validator": "^13.15.3", - "libphonenumber-js": "^1.11.1", - "validator": "^13.15.22" + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "devOptional": true, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "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, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=7.0.0" + "node": ">=18" } }, - "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/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/comment-json": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.5.1.tgz", - "integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", - "esprima": "^4.0.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 6" + "node": ">=18" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "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" + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true, "license": "MIT" }, - "node_modules/consola": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", - "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { - "node": "^14.18.0 || >=16.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "devOptional": true, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, "engines": { - "node": ">= 0.6" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.6.0" + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { - "object-assign": "^4", - "vary": "^1" + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "engines": { - "node": ">= 0.10" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" + "node": "^16.17.0 || >=18.6.0" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/daisyui": { - "version": "5.5.19", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", - "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", - "dev": true, - "license": "MIT", "funding": { - "url": "https://github.com/saadeghi/daisyui?sponsor=1" + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } } }, - "node_modules/dateformat": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, "engines": { - "node": "*" - } - }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "ms": "^2.1.1" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/eslint-plugin-better-tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-better-tailwindcss/-/eslint-plugin-better-tailwindcss-4.3.0.tgz", + "integrity": "sha512-AYNFAeqExOS/yt1qn7kg1il0VZvAxGwBuW/6xAQtmKDRZXkdkWzwWCYF6Ou2/0tza42K8xh4q420DBq+QvXzjA==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@eslint/css-tree": "^3.6.8", + "@valibot/to-json-schema": "^1.5.0", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "synckit": "^0.11.12", + "tailwind-csstree": "^0.1.4", + "tsconfig-paths-webpack-plugin": "^4.2.0", + "valibot": "^1.2.0" }, "engines": { - "node": ">=6.0" + "node": "^20.19.0 || ^22.12.0 || >=23.0.0" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "oxlint": "^1.35.0", + "tailwindcss": "^3.3.0 || ^4.1.17" }, "peerDependenciesMeta": { - "supports-color": { + "eslint": { + "optional": true + }, + "oxlint": { "optional": true } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "node_modules/eslint-plugin-check-file": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-check-file/-/eslint-plugin-check-file-3.3.2.tgz", + "integrity": "sha512-X0yVi4vbV7pSxu00oAJiE8wcwAvIoU2YQKrkvRRLq+88i/cZNooOgAgjk2gOS03lKMsFMVusJLlqHNuAwduX2g==", "dev": true, - "license": "MIT", - "optional": true, + "funding": [ + { + "type": "ko_fi", + "url": "https://ko-fi.com/huanluo" + }, + { + "type": "github", + "url": "https://github.com/sponsors/dukeluo" + } + ], + "license": "Apache-2.0", + "workspaces": [ + "examples/eslint", + "examples/oxlint" + ], "dependencies": { - "mimic-response": "^3.1.0" + "is-glob": "^4.0.3", + "micromatch": "^4.0.8" }, "engines": { - "node": ">=10" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "eslint": ">=9.0.0" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, "engines": { - "node": ">=4.0.0" + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "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==", - "devOptional": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "license": "MIT" - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/eslint-plugin-import/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" } }, - "node_modules/devalue": { - "version": "5.9.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", - "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", - "devOptional": true, - "license": "MIT" - }, - "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==", + "node_modules/eslint-plugin-import/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", - "optional": true + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "license": "MIT", - "optional": true - }, - "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/eslint-plugin-security": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.2.0.tgz", + "integrity": "sha512-LL5jbxRzlNv0vkSyBtbO7qAPofqd3xlseAFTDevyDTMmScjBrLv2/VkQ7ZFQxBaFYsa3dhpE7NcV+JkwJ9l6RQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "safe-regex": "^2.1.1" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/effect": { - "version": "3.22.2", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.22.2.tgz", - "integrity": "sha512-fm1CQXCs7GTMwpadZxwU5rnAb2bN6mbQpknq/pXBGCzSKkosNYb9k0wsoL0TfB10rSa5xOEZJjo77u/KLXSANg==", + "node_modules/eslint-plugin-svelte": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.23.0.tgz", + "integrity": "sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@standard-schema/spec": "^1.0.0", - "fast-check": "^3.23.1" + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.7.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } } }, - "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==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, "engines": { - "node": ">= 0.8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/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/eslint/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "MIT", - "optional": true, + "license": "Apache-2.0", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=0.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } + "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==", + "devOptional": true, + "license": "MIT" }, - "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": ">=10.13.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "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/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, "engines": { - "node": ">=6" + "node": ">=4" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "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==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=0.10" } }, - "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, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "devOptional": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } } }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "devOptional": true, - "license": "MIT" - }, - "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==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "es-errors": "^1.3.0" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" + "node": ">=4.0" } }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "devOptional": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" + "node": ">=4.0" } }, - "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/esbuild/node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">= 0.6" } }, - "node_modules/esbuild/node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/esbuild/node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=0.8.x" } }, - "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "devOptional": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.0.0" } }, - "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, "engines": { - "node": ">=18" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } ], "license": "MIT", "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "pure-rand": "^6.1.0" + }, "engines": { - "node": ">=18" + "node": ">=8.0.0" } }, - "node_modules/esbuild/node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], + "node_modules/fast-copy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.1.tgz", + "integrity": "sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "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", - "optional": true, - "os": [ - "linux" - ], + "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": ">=18" + "node": ">=8.6.0" } }, - "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, "engines": { - "node": ">=18" + "node": ">= 6" } }, - "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-printf": { + "version": "1.6.10", + "resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz", + "integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==", + "dev": true, + "license": "BSD-3-Clause", "engines": { - "node": ">=18" + "node": ">=10.0" } }, - "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "engines": { - "node": ">=18" + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" } }, - "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "devOptional": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "flat-cache": "^4.0.0" + }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "optional": true }, - "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], + "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", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "to-regex-range": "^5.0.1" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, "engines": { - "node": ">=18" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "is-callable": "^1.2.7" + }, "engines": { - "node": ">=18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, "engines": { - "node": ">=18" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], "engines": { - "node": ">=18" + "node": ">= 0.6" } }, - "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">= 0.8" } }, - "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "license": "MIT", + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "minipass": "^7.0.3" + }, "engines": { - "node": ">=18" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/esbuild/node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ], "engines": { - "node": ">=18" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "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", - "engines": { - "node": ">=10" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-better-tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-better-tailwindcss/-/eslint-plugin-better-tailwindcss-4.3.0.tgz", - "integrity": "sha512-AYNFAeqExOS/yt1qn7kg1il0VZvAxGwBuW/6xAQtmKDRZXkdkWzwWCYF6Ou2/0tza42K8xh4q420DBq+QvXzjA==", + "node_modules/fzstd": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz", + "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==", + "license": "MIT" + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "dev": true, "license": "MIT", - "dependencies": { - "@eslint/css-tree": "^3.6.8", - "@valibot/to-json-schema": "^1.5.0", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "synckit": "^0.11.12", - "tailwind-csstree": "^0.1.4", - "tsconfig-paths-webpack-plugin": "^4.2.0", - "valibot": "^1.2.0" - }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23.0.0" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", - "oxlint": "^1.35.0", - "tailwindcss": "^3.3.0 || ^4.1.17" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - }, - "oxlint": { - "optional": true - } + "node": ">= 0.4" } }, - "node_modules/eslint-plugin-security": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.2.0.tgz", - "integrity": "sha512-LL5jbxRzlNv0vkSyBtbO7qAPofqd3xlseAFTDevyDTMmScjBrLv2/VkQ7ZFQxBaFYsa3dhpE7NcV+JkwJ9l6RQ==", + "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": "Apache-2.0", - "dependencies": { - "safe-regex": "^2.1.1" - }, + "license": "ISC", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/eslint-plugin-svelte": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.23.0.tgz", - "integrity": "sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==", + "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": { - "@eslint-community/eslint-utils": "^4.6.1", - "@jridgewell/sourcemap-codec": "^1.5.0", - "esutils": "^2.0.3", - "globals": "^16.0.0", - "known-css-properties": "^0.37.0", - "postcss": "^8.4.49", - "postcss-load-config": "^3.1.4", - "postcss-safe-parser": "^7.0.0", - "semver": "^7.6.3", - "svelte-eslint-parser": "^1.7.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "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": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=8" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "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": "Apache-2.0", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" } }, - "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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "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/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" }, "engines": { - "node": ">=4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "devOptional": true, + "license": "MIT", "dependencies": { - "estraverse": "^5.1.0" + "resolve-pkg-maps": "^1.0.0" }, - "engines": { - "node": ">=0.10" + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/esrap": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", - "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", - "devOptional": true, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, "license": "MIT", + "optional": true + }, + "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", + "dev": true, + "license": "ISC", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "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" }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" + "bin": { + "glob": "dist/esm/bin.mjs" }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "BSD-2-Clause", + "license": "ISC", "dependencies": { - "estraverse": "^5.2.0" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=4.0" + "node": ">=10.13.0" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "BSD-2-Clause", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0", - "optional": true - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { - "node": ">= 18" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "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.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "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": "MIT" + "license": "ISC" }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", - "optional": true + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/fast-check": { - "version": "3.23.2", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", - "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], + "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==", + "devOptional": true, "license": "MIT", - "optional": true, - "dependencies": { - "pure-rand": "^6.1.0" - }, "engines": { - "node": ">=8.0.0" + "node": ">=8" } }, - "node_modules/fast-copy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", - "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "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" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/fast-printf": { - "version": "1.6.10", - "resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz", - "integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { - "node": ">=10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "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" + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "bin": { + "he": "bin/he" } }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "dev": true, "license": "MIT" }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "node_modules/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "flat-cache": "^4.0.0" + "lru-cache": "^11.1.0" }, "engines": { - "node": ">=16.0.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "dev": true, + "license": "BSD-2-Clause", "optional": true }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { - "node": ">= 18.0.0" + "node": ">= 0.8" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 14" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">=16" + "node": ">= 14" } }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" + "node_modules/hyparquet": { + "version": "1.31.2", + "resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.31.2.tgz", + "integrity": "sha512-dpVTCGkPPIdfDExa7YZGaJpuBTmLFDKGeZ6meHUj1mz/Q3qmaQHGLLqCyPBkQjfxrYNb08YTax682qeGO+zkVA==", + "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==", + "node_modules/hyparquet-compressors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/hyparquet-compressors/-/hyparquet-compressors-1.1.2.tgz", + "integrity": "sha512-0Hx6w0ZpVpNCefKy5xBYLTp6rKeZn/tsUzuliNzfKqSCdLcTQSfwATEoeedS703Y17k1qNS61HwRME5aOeNkPg==", + "license": "MIT", + "dependencies": { + "fzstd": "0.1.1", + "hysnappy": "1.1.1" + } + }, + "node_modules/hysnappy": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hysnappy/-/hysnappy-1.1.1.tgz", + "integrity": "sha512-/V9XcN2NtRyWjR4LYMfvnvasVVF8jbT/ej0eofBQjZel91E3D813FQ3mQC6gDSMMTCq/FJh28XHeyqr3I/oBRw==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, - "license": "ISC", - "optional": true, + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=14" + "node": ">=0.10.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "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": ">= 0.8" + "node": ">= 4" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "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", - "optional": true - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "optional": true, "dependencies": { - "minipass": "^7.0.3" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "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==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=0.8.19" } }, - "node_modules/fzstd": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz", - "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==", - "license": "MIT" + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, - "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==", + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "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" + "side-channel": "^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==", + "node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "dev": true, "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, + "optional": true, "engines": { - "node": ">= 0.4" + "node": ">= 12" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "dev": true, "license": "MIT", - "optional": true + "engines": { + "node": ">= 0.10" + } }, - "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", + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, - "license": "ISC", - "optional": true, + "license": "MIT", "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" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "balanced-match": "^1.0.0" + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, - "license": "ISC", - "optional": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.2" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "dev": true, "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, "engines": { "node": ">= 0.4" }, @@ -6457,29 +10394,34 @@ "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==", + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "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==", - "devOptional": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { + "node_modules/is-date-object": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -6487,275 +10429,221 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "call-bound": "^1.0.4" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "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", - "bin": { - "he": "bin/he" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/help-me": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "lru-cache": "^11.1.0" + "call-bound": "^1.0.3" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=8" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/http-cache-semantics": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", - "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "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", - "optional": true, "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 14" + "node": ">=0.10.0" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, "engines": { - "node": ">= 14" - } - }, - "node_modules/hyparquet": { - "version": "1.31.2", - "resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.31.2.tgz", - "integrity": "sha512-dpVTCGkPPIdfDExa7YZGaJpuBTmLFDKGeZ6meHUj1mz/Q3qmaQHGLLqCyPBkQjfxrYNb08YTax682qeGO+zkVA==", - "license": "MIT" - }, - "node_modules/hyparquet-compressors": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/hyparquet-compressors/-/hyparquet-compressors-1.1.2.tgz", - "integrity": "sha512-0Hx6w0ZpVpNCefKy5xBYLTp6rKeZn/tsUzuliNzfKqSCdLcTQSfwATEoeedS703Y17k1qNS61HwRME5aOeNkPg==", - "license": "MIT", - "dependencies": { - "fzstd": "0.1.1", - "hysnappy": "1.1.1" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hysnappy": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/hysnappy/-/hysnappy-1.1.1.tgz", - "integrity": "sha512-/V9XcN2NtRyWjR4LYMfvnvasVVF8jbT/ej0eofBQjZel91E3D813FQ3mQC6gDSMMTCq/FJh28XHeyqr3I/oBRw==", + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, "license": "MIT" }, - "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==", + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "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==", + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": ">= 4" + "node": ">=0.12.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/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=6" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "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": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "license": "MIT" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", "dev": true, - "license": "ISC", - "optional": true + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } }, - "node_modules/ip-address": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", - "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, "engines": { - "node": ">= 12" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -6764,77 +10652,123 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "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==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "*" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6887,7 +10821,6 @@ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", - "optional": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -6979,6 +10912,95 @@ "dev": true, "license": "MIT" }, + "node_modules/json-colorizer": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/json-colorizer/-/json-colorizer-2.2.2.tgz", + "integrity": "sha512-56oZtwV1piXrQnRNTtJeqRv+B9Y/dXAYLqBBaYl/COcUdoZxgLBLAO88+CnkbT6MxNs0c5E9mPBIb2sFcNz3vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.1", + "lodash.get": "^4.4.2" + } + }, + "node_modules/json-colorizer/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/json-colorizer/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-colorizer/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/json-colorizer/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/json-colorizer/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/json-colorizer/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/json-colorizer/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/json-schema-to-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -7046,15 +11068,61 @@ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } }, - "node_modules/kysely": { - "version": "0.28.17", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", - "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=20.0.0" + "dependencies": { + "safe-buffer": "~5.1.0" } }, "node_modules/levn": { @@ -7398,6 +11466,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7405,13 +11495,19 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "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==", "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/luxon": { "version": "3.5.0", @@ -7544,6 +11640,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/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/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -7614,7 +11747,6 @@ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "license": "BlueOak-1.0.0", - "optional": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -7774,13 +11906,28 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/mlly": { "version": "1.8.2", @@ -7909,6 +12056,22 @@ "license": "MIT", "optional": true }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7972,6 +12135,35 @@ "node": ">=10" } }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-gyp": { "version": "11.5.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", @@ -8026,6 +12218,66 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-stream": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/node-stream/-/node-stream-1.7.0.tgz", + "integrity": "sha512-AB1qHzJWjAuxpDvTr/n1wvKVOg8c9BjAHV21QXq+q9yEUNr7wSqfHmAhAzvpQWSbf8mQQle3fjsnu3R14jrElA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.2", + "readable-stream": "^2.3.3", + "split2": "^2.1.0", + "stream-combiner2": "^1.1.1", + "through2": "^2.0.1" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/node-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/node-stream/node_modules/split2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-2.2.0.tgz", + "integrity": "sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "through2": "^2.0.2" + } + }, + "node_modules/node-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", @@ -8058,6 +12310,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", @@ -8115,6 +12377,106 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -8176,6 +12538,25 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/oxc-minify": { "version": "0.136.0", "resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.136.0.tgz", @@ -8262,8 +12643,7 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, - "license": "BlueOak-1.0.0", - "optional": true + "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { "version": "1.6.0", @@ -8286,6 +12666,12 @@ "dev": true, "license": "MIT" }, + "node_modules/papaparse": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8342,7 +12728,6 @@ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", - "optional": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -8372,6 +12757,102 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pegjs-backtrace": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/pegjs-backtrace/-/pegjs-backtrace-0.2.1.tgz", + "integrity": "sha512-rnVQiHyTE1wZG14Vl3Xk33ecrF7ZJ7ZW7jSgSlw4LdzBuhbyGVQ+oVApQ6tRi4QsII/xHgByHb6Ax68K6SPLhw==", + "dev": true, + "license": "ISC" + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8479,6 +12960,27 @@ "pathe": "^2.0.3" } }, + "node_modules/plantuml-parser": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/plantuml-parser/-/plantuml-parser-0.4.0.tgz", + "integrity": "sha512-IwbkQNgQK/kvXbSYxZWZpcAItk46ECZm6QFA66+smFZqSIjdglXGNTFniO2VLPpgt8uY8EE0uLOsGgvBrerU5Q==", + "dev": true, + "license": " Apache-2.0", + "dependencies": { + "async": "^3.2.0", + "fast-glob": "^3.2.4", + "get-stdin": "^8.0.0", + "json-colorizer": "^2.2.2", + "pegjs-backtrace": "^0.2.0", + "read-vinyl-file-stream": "^2.0.3", + "require-dir": "^1.2.0", + "serialize-error": "^7.0.1", + "yargs": "^16.0.3" + }, + "bin": { + "plantuml-parser": "dist/bin/cli.js" + } + }, "node_modules/playwright": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", @@ -8521,6 +13023,16 @@ "node": ">=14.19.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -8658,6 +13170,45 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -8826,6 +13377,23 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -8870,6 +13438,43 @@ "node": ">=10" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/properties-reader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-3.0.1.tgz", + "integrity": "sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "mkdirp": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, "node_modules/property-expr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", @@ -8878,10 +13483,34 @@ "license": "MIT", "optional": true }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8890,6 +13519,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pump": { @@ -8965,6 +13598,27 @@ ], "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -9048,6 +13702,17 @@ "node": ">=0.10.0" } }, + "node_modules/read-vinyl-file-stream": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/read-vinyl-file-stream/-/read-vinyl-file-stream-2.0.3.tgz", + "integrity": "sha512-ZbtobBf+n/va3eRcIkMDYsp7DCnnjh46YFOOdj42aCiWFirp9T/+YGMCTfVpEFIuiH3c5Kp13jpn3i5DoygxLw==", + "dev": true, + "license": "ISC", + "dependencies": { + "node-stream": "^1.5.0", + "through2": "^2.0.1" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -9062,6 +13727,39 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -9085,6 +13783,29 @@ "node": ">= 12.13.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -9095,6 +13816,59 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/require-dir": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/require-dir/-/require-dir-1.2.0.tgz", + "integrity": "sha512-LY85DTSu+heYgDqq/mK+7zFHWkttVNRXC9NKcKGyuGLdlsfbjEPrIEYdCVrx6hqnJb+xSu3Lzaoo8VnmOhhjNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -9137,6 +13911,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", @@ -9154,11 +13938,21 @@ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.63.6", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.6.tgz", @@ -9228,6 +14022,30 @@ "node": ">= 18" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -9241,6 +14059,26 @@ "node": ">=6" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -9248,6 +14086,23 @@ "dev": true, "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", @@ -9258,6 +14113,24 @@ "regexp-tree": "~0.1.1" } }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -9342,6 +14215,35 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -9368,6 +14270,55 @@ "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9487,7 +14438,6 @@ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", - "optional": true, "engines": { "node": ">=14" }, @@ -9626,16 +14576,37 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.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==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -9672,6 +14643,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -9681,6 +14659,54 @@ "node": ">= 10.x" } }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", + "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^0.5.48", + "ssh2": "^1.4.0" + } + }, + "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2/node_modules/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -9695,6 +14721,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -9719,6 +14755,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -9729,6 +14779,61 @@ "readable-stream": "^3.5.0" } }, + "node_modules/stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "node_modules/stream-combiner2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-combiner2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/stream-combiner2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9764,7 +14869,6 @@ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -9784,7 +14888,6 @@ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -9800,7 +14903,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -9810,8 +14912,7 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", @@ -9819,7 +14920,6 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -9827,6 +14927,66 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringtemplate4ts": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/stringtemplate4ts/-/stringtemplate4ts-1.0.9.tgz", @@ -9853,7 +15013,6 @@ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^6.2.2" }, @@ -9871,7 +15030,6 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -9885,7 +15043,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -10215,7 +15372,6 @@ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -10223,13 +15379,12 @@ "tar-stream": "^2.1.4" } }, - "node_modules/tar-stream": { + "node_modules/tar-fs/node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -10241,6 +15396,18 @@ "node": ">=6" } }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar/node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -10252,25 +15419,139 @@ "node": ">=18" } }, - "node_modules/tdigest": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", - "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/testcontainers": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.1.0.tgz", + "integrity": "sha512-YjDLqIITuhGLMnM10yhg3oV6lIG5IMpz1R1DPBZoOOks83q7i7IVpeSWRTiyl7roozjiyLmwIoLK/KY8OnZmIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^4.0.1", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.4.3", + "docker-compose": "^1.4.2", + "dockerode": "^5.0.1", + "get-port": "^5.1.1", + "proper-lockfile": "^4.1.2", + "properties-reader": "^3.0.1", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.1.3", + "tmp": "^0.2.7", + "undici": "^8.9.0" + }, + "engines": { + "node": ">= 22.22" + } + }, + "node_modules/testcontainers/node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/testcontainers/node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { - "bintrees": "1.0.2" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/thread-stream": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", - "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { - "real-require": "^0.2.0" - }, - "engines": { - "node": ">=20" + "safe-buffer": "~5.1.0" } }, "node_modules/tiny-case": { @@ -10332,6 +15613,29 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -10475,6 +15779,13 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10543,6 +15854,83 @@ "license": "MIT", "optional": true }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -10588,6 +15976,25 @@ "dev": true, "license": "MIT" }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici": { "version": "7.30.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", @@ -10717,6 +16124,44 @@ } } }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -11040,6 +16485,95 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -11073,7 +16607,6 @@ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -11093,7 +16626,6 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -11112,7 +16644,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -11122,8 +16653,7 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", @@ -11131,7 +16661,6 @@ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -11147,7 +16676,6 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -11161,7 +16689,6 @@ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=12" }, @@ -11214,6 +16741,25 @@ "node": ">=20.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -11225,6 +16771,96 @@ "node": ">=18" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -11259,6 +16895,38 @@ "devOptional": true, "license": "MIT" }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index a05ba604..254dff91 100644 --- a/package.json +++ b/package.json @@ -12,35 +12,53 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write --ignore-path .prettierignore .", - "lint": "prettier --check --ignore-path .prettierignore . && eslint .", - "lint:fix": "prettier --write --ignore-path .prettierignore . && eslint . --fix", + "lint": "prettier --check --ignore-path .prettierignore . && eslint . && npm run check && npm run test:arch", + "lint:fix": "prettier --write --ignore-path .prettierignore . && eslint . --fix && npm run check && npm run test:arch", "test:unit": "vitest run", + "test:arch": "vitest run --config vitest.arch.config.ts", + "test:arch:report": "ARCH_REPORT=1 vitest run --config vitest.arch.config.ts", "test:unit:coverage": "vitest run --coverage", "test:e2e": "node --env-file=.env.test node_modules/.bin/vite build && playwright test", - "test:e2e:garage": "./e2e/run-garage-tests.sh", "test:e2e:ui": "playwright test --ui", - "generate:antlr": "cd src/lib/editor/grammar && antlr-ng -v -l -Dlanguage=TypeScript -o ../generated SqlBase.g4 && node patch-generated.mjs" + "generate:antlr": "cd src/lib/editor/grammar && antlr-ng -v -l -Dlanguage=TypeScript -o ../generated SqlBase.g4 && node patch-generated.mjs", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:migrate:run": "tsx src/lib/server/migrate.ts", + "db:studio": "drizzle-kit studio" }, "devDependencies": { "@cyclonedx/cyclonedx-npm": "^6.0.0", "@eslint/compat": "^2.0.2", "@eslint/js": "^9.39.2", "@faker-js/faker": "^10.4.0", + "@iconify-json/lsicon": "^1.2.5", + "@iconify-json/material-icon-theme": "^1.2.67", "@iconify-json/material-symbols": "^1.2.74", + "@iconify-json/vscode-icons": "^1.2.55", "@inlang/paraglide-js": "^2.12.0", "@playwright/test": "1.61.1", "@sveltejs/adapter-node": "^5.5.3", "@sveltejs/kit": "^2.53.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", + "@testcontainers/postgresql": "^12.0.1", + "@types/adm-zip": "^0.5.8", "@types/node": "^24", + "@types/papaparse": "^5.5.2", + "@types/pg": "^8.20.0", + "@types/tar-stream": "^3.1.4", "@vitest/browser-playwright": "^4.1.11", "@vitest/coverage-v8": "^4.1.11", "antlr-ng": "^1.0.10", + "archunit": "^2.3.3", "daisyui": "^5.5.19", + "drizzle-kit": "^0.31.10", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-better-tailwindcss": "^4.3.0", + "eslint-plugin-check-file": "^3.3.2", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-security": "^4.0.0", "eslint-plugin-svelte": "^3.15.0", "globals": "^16.5.0", @@ -53,6 +71,7 @@ "svelte-check": "^4.4.3", "sveltekit-superforms": "^2.30.0", "tailwindcss": "^4.2.1", + "testcontainers": "^12.0.1", "tsx": "^4.23.13", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", @@ -65,17 +84,36 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1041.0", "@aws-sdk/lib-storage": "^3.1045.0", + "@better-auth/drizzle-adapter": "^1.6.20", "@internationalized/date": "^3.11.0", "@smithy/node-http-handler": "4.9.1", + "adm-zip": "^0.6.1", "antlr4-c3": "^3.4.4", "antlr4ng": "^3.0.16", "better-auth": "^1.6.33", + "drizzle-orm": "^0.45.2", "hyparquet": "^1.25.8", "hyparquet-compressors": "^1.1.1", "monaco-editor": "^0.55.1", + "papaparse": "^5.5.3", + "pg": "^8.21.0", "pino": "^10.3.1", "pretty-bytes": "^7.1.0", "prom-client": "^15.1.3", + "regexpp": "^3.2.0", + "tar-stream": "^3.2.0", "undici": "^7.24.4" + }, + "//overrides": { + "@sveltejs/kit > cookie": "SvelteKit requests ^0.6.0, which excludes the cookie validation security fix in 0.7.x. Remove when SvelteKit requires a patched version.", + "monaco-editor > dompurify": "Monaco pins DOMPurify to 3.2.7. Use ^3.4.16 for subsequent sanitisation security fixes; remove when Monaco requires an equally patched or newer version." + }, + "overrides": { + "@sveltejs/kit": { + "cookie": "^0.7.2" + }, + "monaco-editor": { + "dompurify": "^3.4.16" + } } } diff --git a/playwright.config.ts b/playwright.config.ts index c69f0f12..1da44e3c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,13 +2,20 @@ import { defineConfig } from '@playwright/test'; import path from 'path'; const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:4173'; +const appPort = new URL(baseURL).port || '80'; const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; export default defineConfig({ + // Each worker logs in as its own user (see e2e/support/fixtures.ts), so + // tests can run in parallel without racing on per-user server-side state. + fullyParallel: true, + // CI runners have few cores; keep their load low. + workers: process.env.CI ? 2 : 4, testDir: path.join(import.meta.dirname, 'e2e'), outputDir: path.join(import.meta.dirname, 'e2e/test-results'), globalSetup: path.join(import.meta.dirname, 'e2e/support/global-setup.ts'), - timeout: 60_000, + timeout: 20_000, + globalTimeout: 40 * 60_000, retries: 2, expect: { timeout: 10_000 @@ -16,7 +23,8 @@ export default defineConfig({ use: { baseURL, trace: 'retain-on-failure', - video: 'retain-on-failure' + video: 'retain-on-failure', + screenshot: 'only-on-failure' }, webServer: [ { @@ -30,79 +38,31 @@ export default defineConfig({ reuseExistingServer: false }, { - command: 'PORT=4173 node --env-file=.env.test build', + command: 'npx tsx e2e/support/start-app.ts', + env: { PORT: appPort }, url: baseURL, - reuseExistingServer: true + timeout: 300_000, + // App logs drown out the test output; set E2E_SERVER_LOGS=1 to see them. + stdout: process.env.E2E_SERVER_LOGS ? 'pipe' : 'ignore', + gracefulShutdown: { signal: 'SIGTERM', timeout: 30_000 }, + reuseExistingServer: false } ], projects: [ - // Each browser project gets its own auth setup so that parallel workers - // log in as different users. This prevents cross-worker races on shared - // server-side state (e.g. the in-memory Trino connection store). { - name: 'setup-chromium', - testMatch: /auth\.setup\.ts/, - use: { - browserName: 'chromium', - viewport: { width: 1280, height: 720 } - } - }, - { - name: 'setup-firefox', - testMatch: /auth\.setup\.ts/, + name: 'firefox', use: { browserName: 'firefox', viewport: { width: 1280, height: 720 } } }, - ...(!process.env.CI - ? [ - { - name: 'setup-mobile', - testMatch: /auth\.setup\.ts/, - use: { - browserName: 'chromium' as const, - viewport: { width: 393, height: 851 } - } - } - ] - : []), - { - name: 'firefox', - use: { - browserName: 'firefox', - viewport: { width: 1280, height: 720 }, - storageState: 'e2e/.auth/user-setup-firefox.json' - }, - dependencies: ['setup-firefox'] - }, { name: 'chromium', use: { browserName: 'chromium', viewport: { width: 1280, height: 720 }, - storageState: 'e2e/.auth/user-setup-chromium.json', ...(chromiumExecutablePath && { launchOptions: { executablePath: chromiumExecutablePath } }) - }, - dependencies: ['setup-chromium'] - }, - ...(!process.env.CI - ? [ - { - name: 'mobile', - use: { - browserName: 'chromium' as const, - viewport: { width: 393, height: 851 }, - isMobile: true, - hasTouch: true, - storageState: 'e2e/.auth/user-setup-mobile.json', - ...(chromiumExecutablePath && { - launchOptions: { executablePath: chromiumExecutablePath } - }) - }, - dependencies: ['setup-mobile'] - } - ] - : []) + } + } ] }); diff --git a/src/app.css b/src/app.css index 07487488..4820441b 100644 --- a/src/app.css +++ b/src/app.css @@ -34,6 +34,14 @@ progress.progress::-moz-progress-bar { transition: none; } +/* Smooth transition for operation progress bars where discrete chunk updates benefit from animation */ +progress.progress.progress-smooth::-webkit-progress-value { + transition: width 0.4s ease; +} +progress.progress.progress-smooth::-moz-progress-bar { + transition: width 0.4s ease; +} + /* * Visible scrollbars for preview panels. Uses DaisyUI semantic color variables * so the thumb adapts to both light and dark themes automatically. @@ -64,3 +72,19 @@ progress.progress::-moz-progress-bar { background-color: color-mix(in oklch, currentColor 50%, transparent); } } + +/* Align a modal to the top of the viewport like a command palette while + keeping it horizontally centred. DaisyUI's .modal uses a grid with + place-items:center, so only the vertical alignment and top inset change. */ +dialog.modal-top-search { + align-items: start; + padding-top: 2vh; +} +@keyframes nav-progress-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(350%); + } +} diff --git a/src/app.d.ts b/src/app.d.ts index f31b166a..f37d7a7d 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -9,6 +9,10 @@ declare global { namespace App { interface Error { requestId?: string; + /** Set when the error page should explain this specific cause. */ + reason?: 'not-connected'; + /** Hostname of the storage a link points to, for `reason: 'not-connected'`. */ + connection?: string; } interface Locals { user: typeof auth.$Infer.Session.user | null; @@ -16,16 +20,22 @@ declare global { logger: import('pino').Logger; requestId: string; /** - * Parsed S3 connection config extracted from the `x-storage-connection` - * request header by the `handleStorageConnection` middleware. + * Parsed S3 connection config extracted from the `x-storage-connection-id` + * request header by the `handleStorageConnection` middleware. The connection is + * looked up from the database and decrypted using the application key. * Always non-null for requests to `/(app)/api/storage/*` routes * (the middleware throws 401 before the handler runs if the header is absent). * Null for all other routes. */ storageConfig: import('$lib/server/storage/types.js').S3ConnectionConfig | null; + /** Authorised storage connection ID for the current API request. */ + storageConnectionId: string | null; } // interface PageData {} - // interface PageState {} + interface PageState { + /** Previous-page continuation tokens of the storage listing in this history entry. */ + storagePrevTokens?: (string | null)[]; + } // interface Platform {} } } diff --git a/src/app.html b/src/app.html index 023799df..ea0df32a 100644 --- a/src/app.html +++ b/src/app.html @@ -6,10 +6,18 @@ %sveltekit.head% diff --git a/src/architecture-tests/circular-dependencies.spec.ts b/src/architecture-tests/circular-dependencies.spec.ts new file mode 100644 index 00000000..b1b2ae15 --- /dev/null +++ b/src/architecture-tests/circular-dependencies.spec.ts @@ -0,0 +1,50 @@ +/** + * No Circular Dependencies + * + * Import cycles cause unpredictable initialisation order, make tree-shaking + * less effective and are a strong signal of coupled, hard-to-refactor code. + * + * Known violations: + * • src/lib/server/trino/client.ts ↔ src/lib/server/trino/user-clients.ts + * • src/lib/server/trino/queries.ts ↔ src/lib/server/trino/result-collector.ts + * + * These are excluded from the cycle check below until the Trino layer is + * refactored to extract shared types into a separate file. + */ + +import { projectFiles } from 'archunit'; +import { describe, expect, it } from 'vitest'; +import { defaultOptions } from './helpers'; + +describe('No Circular Dependencies', () => { + it('src/lib must be free of import cycles (excluding generated and known-cyclic Trino files)', async () => { + const rule = projectFiles() + .inPath('src/lib/**/*.ts', { + except: { + inPath: 'src/lib/editor/generated/**' + } + }) + .inPath('src/lib/**/*.ts', { + except: { + inPath: 'src/lib/server/trino/**' + } + }) + .should() + .haveNoCycles(); + + await expect(rule).toPassAsync(defaultOptions); + }); + + it('SvelteKit routes must be free of import cycles', async () => { + const rule = projectFiles().inPath('src/routes/**/*.ts').should().haveNoCycles(); + + await expect(rule).toPassAsync(defaultOptions); + }); + + it('KNOWN VIOLATION — Trino layer has circular imports', async () => { + const rule = projectFiles().inPath('src/lib/server/trino/**/*.ts').should().haveNoCycles(); + + const violations = await rule.check(defaultOptions); + expect(violations.length).toBeGreaterThan(0); + }); +}); diff --git a/src/architecture-tests/helpers.ts b/src/architecture-tests/helpers.ts new file mode 100644 index 00000000..d2cb4d68 --- /dev/null +++ b/src/architecture-tests/helpers.ts @@ -0,0 +1,67 @@ +/** + * Shared helpers for architecture fitness tests. + * + * Some rules inspect Svelte and JSON source text directly. These helpers + * provide recursive discovery and content checks for those rules. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Standard options: suppress verbose logs, fail on empty patterns. */ +export const defaultOptions = { + logging: { enabled: false, level: 'warn' as const } +}; + +// These tests deliberately inspect a discovered source tree rather than +// application-controlled input paths. +/* eslint-disable security/detect-non-literal-fs-filename */ +export function readTextFile(file: string): string { + return readFileSync(file, 'utf-8'); +} + +/** + * Recursively collect all files under `dir` whose names match `filenamePattern`. + * Hidden directories (starting with `.`) are skipped automatically. + */ +export function findFiles( + dir: string, + filenamePattern: RegExp, + excludeDirs: RegExp[] = [] +): string[] { + const results: string[] = []; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name.startsWith('.')) continue; + if (excludeDirs.some((p) => p.test(fullPath))) continue; + results.push(...findFiles(fullPath, filenamePattern, excludeDirs)); + } else if (entry.isFile() && filenamePattern.test(entry.name)) { + results.push(fullPath); + } + } + return results; +} + +/** + * Check every file returned by `findFiles` against `predicate`. + * Returns a list of `{ file, reason }` violation objects. + */ +export function checkFiles( + files: string[], + predicate: (content: string) => boolean, + reason: string +): Array<{ file: string; reason: string }> { + return files + .filter((file) => { + const content = readTextFile(file); + return !predicate(content); + }) + .map((file) => ({ file, reason })); +} diff --git a/src/architecture-tests/i18n-compliance.spec.ts b/src/architecture-tests/i18n-compliance.spec.ts new file mode 100644 index 00000000..d982ae8e --- /dev/null +++ b/src/architecture-tests/i18n-compliance.spec.ts @@ -0,0 +1,145 @@ +/** + * i18n Compliance + * + * All user-visible strings must be served through Paraglide-JS message + * functions so that the application renders correctly in both English (en) + * and German (de). + * + * Rules: + * a) Both locale files (messages/en.json, messages/de.json) must have the + * same top-level keys — missing translations break the German locale. + * b) Svelte components must not use hardcoded English strings in static + * aria-label="..." attributes (dynamic bindings are allowed). + * c) Every message key must be referenced somewhere in `src/` so dead keys + * are caught at CI time rather than accumulating in the locale files. + * Pre-existing unused keys are tracked in KNOWN_UNUSED_KEYS; remove an + * entry from that list as soon as the key is used or deleted. + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { findFiles, readTextFile } from './helpers'; + +/** + * Keys present in messages/*.json but not referenced anywhere in src/. + * These predate the unused-key rule and belong to planned features or + * removed UI. As keys are used or removed, delete them from this list — it + * must never grow. + */ +const KNOWN_UNUSED_KEYS = [ + 'nav_badge_soon', + 'trino_first_page', + 'trino_prev_page', + 'trino_next_page', + 'trino_last_page', + 'trino_connection_error', + 'trino_catalog_label', + 'trino_schema_label', + 'storage_connect_reconnecting', + 'storage_connect_timeout', + 'storage_connect_cancel', + 'storage_connect_forget_confirm', + 'storage_connect_forget_cancel', + 'storage_connect_name', + 'storage_connect_name_placeholder', + 'storage_connect_additional_buckets', + 'storage_connect_additional_buckets_hint', + 'storage_connect_duplicate_warning', + 'storage_connect_delete_label', + 'storage_connect_delete_confirm_title', + 'storage_connect_delete_confirm_message', + 'storage_connect_delete_confirm_button', + 'storage_connect_delete_cancel', + 'storage_connect_add_new', + 'storage_connect_testing', + 'storage_connection_edit_success', + 'storage_connection_edit_not_found', + 'storage_folders', + 'storage_paste', + 'storage_paste_plural', + 'storage_danger_zone', + 'storage_download_error_title', + 'storage_preview_fetch_more', + 'storage_preview_csv_columns', + 'storage_preview_pdf_too_large', + 'storage_upload_selected', + 'storage_upload_uploading', + 'storage_upload_success', + 'storage_upload_overwrite_title', + 'storage_upload_overwrite_message', + 'storage_upload_overwrite_replace', + 'storage_upload_overwrite_rename', + 'storage_upload_rename_confirm', + 'storage_upload_retry', + 'storage_preview_parquet_row_group', + 'storage_archive_exit', + 'storage_details_title', + 'storage_details_property', + 'storage_details_value', + 'storage_details_tree_visualization', + 'storage_details_lifecycle_rule_id', + 'storage_details_lifecycle_status', + 'storage_details_error_not_connected', + 'storage_details_error_fetch_file', + 'storage_details_error_fetch_bucket', + 'storage_details_error_fetch_dir_meta', + 'storage_details_tab_overview', + 'storage_details_tab_lifecycle', + 'storage_details_bucket_name', + 'storage_details_object_lock', + 'storage_action_move_error_not_connected', + 'storage_action_move_error_access_denied', + 'storage_operations_progress', + 'storage_operations_error_generic', + 'storage_rename_error_not_connected' +]; + +/** Every message key referenced as `m.(...)` in src/** (excluding the generated paraglide output). */ +function collectUsedKeys(): Set { + const used = new Set(); + for (const file of findFiles('src', /\.(ts|svelte)$/, [/paraglide/])) { + const content = readFileSync(file, 'utf-8'); + for (const match of content.matchAll(/\bm\.([a-zA-Z0-9_]+)\b/g)) used.add(match[1]); + } + return used; +} + +describe('i18n Compliance', () => { + it('messages/en.json and messages/de.json must have the same top-level keys', () => { + const en: Record = JSON.parse(readFileSync('messages/en.json', 'utf-8')); + const de: Record = JSON.parse(readFileSync('messages/de.json', 'utf-8')); + + const enKeys = Object.keys(en).sort(); + const deKeys = Object.keys(de).sort(); + + const missingInDe = enKeys.filter((k) => !deKeys.includes(k)); + const missingInEn = deKeys.filter((k) => !enKeys.includes(k)); + + expect(missingInDe).toStrictEqual([]); + expect(missingInEn).toStrictEqual([]); + }); + + it('every message key must be referenced in the source code', () => { + const en: Record = JSON.parse(readFileSync('messages/en.json', 'utf-8')); + const used = collectUsedKeys(); + + const unused = Object.keys(en) + .filter((key) => !key.startsWith('$')) + .filter((key) => !used.has(key)); + + const newUnused = unused.filter((key) => !KNOWN_UNUSED_KEYS.includes(key)); + expect(newUnused).toStrictEqual([]); + }); + + it('Svelte components must not use static English strings in aria-label attributes', () => { + const STATIC_ARIA_RE = /aria-label="[A-Za-z][^"]{2,}"/; + + const files = findFiles('src', /\.svelte$/); + const allViolations = files.filter((file) => { + const content = readTextFile(file); + return STATIC_ARIA_RE.test(content); + }); + + expect(allViolations).toStrictEqual([]); + }); +}); diff --git a/src/architecture-tests/report-generation.spec.ts b/src/architecture-tests/report-generation.spec.ts new file mode 100644 index 00000000..66ced76e --- /dev/null +++ b/src/architecture-tests/report-generation.spec.ts @@ -0,0 +1,50 @@ +/** + * Architecture Reports + * + * Run `npm run test:arch:report` to generate HTML dashboards in /reports/. + * These can be committed as CI artefacts for architecture review. + */ + +import { describe, expect, it } from 'vitest'; + +describe('Architecture Reports (run manually with test:arch:report)', () => { + it('should export dependency graph for src/lib', async () => { + if (!process.env.ARCH_REPORT) { + expect(true).toBe(true); + return; + } + + const { projectGraph } = await import('archunit'); + await projectGraph() + .titled('Stackable Cockpit — src/lib Dependency Graph') + .focusOn('src/lib/**', 2) + .exportAsHTML('reports/lib-dependency-graph.html'); + + await projectGraph() + .titled('Stackable Cockpit — Full Source Graph') + .collapseToFolderDepth(3) + .exportAsMermaid('reports/source-graph.mmd'); + + expect(true).toBe(true); + }); + + it('should export code metrics report', async () => { + if (!process.env.ARCH_REPORT) { + expect(true).toBe(true); + return; + } + + const { metrics } = await import('archunit'); + await metrics() + .inPath('src/**/*.ts', { + except: { inPath: 'src/lib/editor/generated/**' } + }) + .count() + .exportAsHTML('reports/count-metrics.html', { + title: 'Stackable Cockpit — Count Metrics', + includeTimestamp: true + }); + + expect(true).toBe(true); + }); +}); diff --git a/src/architecture-tests/ui-pattern-enforcement.spec.ts b/src/architecture-tests/ui-pattern-enforcement.spec.ts new file mode 100644 index 00000000..4adfae29 --- /dev/null +++ b/src/architecture-tests/ui-pattern-enforcement.spec.ts @@ -0,0 +1,58 @@ +/** + * UI Pattern Enforcement + * + * These rules enforce the coding standards from AGENTS.md that apply to Svelte + * component files. They inspect raw file content via Node.js fs since ArchUnitTS + * does not scan .svelte files. + * + * Rules: + * a) No native / — always + * use the shared component. + * b) No without an alt attribute — BITV 2.0 / WCAG 2.1 AA compliance. + * c) No
/ — use +
+ + diff --git a/src/lib/components/DateTimePicker.svelte.spec.ts b/src/lib/components/DateTimePicker.svelte.spec.ts new file mode 100644 index 00000000..b5d84b0e --- /dev/null +++ b/src/lib/components/DateTimePicker.svelte.spec.ts @@ -0,0 +1,94 @@ +import { page, userEvent } from 'vitest/browser'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import DateTimePicker from './DateTimePicker.svelte'; + +describe('DateTimePicker', () => { + it('passes aria-describedby to the text input', async () => { + render(DateTimePicker, { id: 'date', value: 'nope', invalid: true, describedby: 'date-error' }); + const input = page.getByRole('textbox'); + await expect.element(input).toHaveAttribute('aria-describedby', 'date-error'); + await expect.element(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('keeps a day in the tab order after changing month', async () => { + render(DateTimePicker, { value: '2027-03-31' }); + await page.getByRole('button', { name: 'Open date picker' }).click(); + const picker = page.getByRole('dialog', { name: 'Date picker' }); + await expect.element(picker).toBeVisible(); + + const previous = page.getByRole('button', { name: 'Previous month' }); + await previous.click(); + await expect.element(previous).toHaveFocus(); + // March 31st clamps to the last day of February. + await expect + .element(picker.getByRole('button', { name: /February 28, 2027/ })) + .toHaveAttribute('tabindex', '0'); + + await userEvent.tab(); + await userEvent.tab(); + await expect.element(picker.getByRole('button', { name: /February 28, 2027/ })).toHaveFocus(); + }); + + it('returns focus to the trigger on Escape', async () => { + render(DateTimePicker, { value: '2027-03-15' }); + const trigger = page.getByRole('button', { name: 'Open date picker' }); + await trigger.click(); + const picker = page.getByRole('dialog', { name: 'Date picker' }); + await expect.element(picker.getByRole('button', { name: /March 15, 2027/ })).toHaveFocus(); + + await userEvent.keyboard('{Escape}'); + await expect.poll(() => picker.query()).toBeNull(); + await expect.element(trigger).toHaveFocus(); + }); + + it('writes the picked day and returns focus to the trigger', async () => { + render(DateTimePicker, { value: '2027-03-15' }); + const trigger = page.getByRole('button', { name: 'Open date picker' }); + await trigger.click(); + const picker = page.getByRole('dialog', { name: 'Date picker' }); + await picker.getByRole('button', { name: /March 20, 2027/ }).click(); + + await expect.poll(() => picker.query()).toBeNull(); + await expect.element(page.getByRole('textbox')).toHaveValue('2027-03-20'); + await expect.element(trigger).toHaveFocus(); + }); + + it('returns focus to the trigger when dismissed on empty space', async () => { + const empty = document.createElement('div'); + empty.dataset.testid = 'empty'; + empty.style.height = '100px'; + document.body.append(empty); + try { + render(DateTimePicker, { value: '2027-03-15' }); + const trigger = page.getByRole('button', { name: 'Open date picker' }); + await trigger.click(); + const picker = page.getByRole('dialog', { name: 'Date picker' }); + await expect.element(picker.getByRole('button', { name: /March 15, 2027/ })).toHaveFocus(); + + await page.getByTestId('empty').click(); + await expect.poll(() => picker.query()).toBeNull(); + await expect.element(trigger).toHaveFocus(); + } finally { + empty.remove(); + } + }); + + it('leaves focus on another control clicked while open', async () => { + const outside = document.createElement('input'); + outside.setAttribute('aria-label', 'Outside'); + document.body.append(outside); + try { + render(DateTimePicker, { value: '2027-03-15' }); + await page.getByRole('button', { name: 'Open date picker' }).click(); + const picker = page.getByRole('dialog', { name: 'Date picker' }); + await expect.element(picker).toBeVisible(); + + await page.getByRole('textbox', { name: 'Outside' }).click(); + await expect.poll(() => picker.query()).toBeNull(); + await expect.element(page.getByRole('textbox', { name: 'Outside' })).toHaveFocus(); + } finally { + outside.remove(); + } + }); +}); diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 99558c69..6d58afcb 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -1,13 +1,35 @@ + + - + {#if open} {@render children()} {/if} diff --git a/src/lib/components/Pagination.svelte b/src/lib/components/Pagination.svelte index 6aad8534..280d36c1 100644 --- a/src/lib/components/Pagination.svelte +++ b/src/lib/components/Pagination.svelte @@ -106,43 +106,47 @@
{#if showNavButtons} {/if} @@ -162,43 +166,47 @@ {infoLabel} {/if}
diff --git a/src/lib/components/TabBar.svelte b/src/lib/components/TabBar.svelte index d7f9e351..ebeb79d8 100644 --- a/src/lib/components/TabBar.svelte +++ b/src/lib/components/TabBar.svelte @@ -1,5 +1,6 @@ - -
+
{#each toasts as toast (toast.id)} {@const ToastIcon = iconMap[toast.type]} {/each}
diff --git a/src/lib/components/ToastHost.svelte.spec.ts b/src/lib/components/ToastHost.svelte.spec.ts new file mode 100644 index 00000000..114dc574 --- /dev/null +++ b/src/lib/components/ToastHost.svelte.spec.ts @@ -0,0 +1,50 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import ToastHost from './ToastHost.svelte'; +import { toasts, addToast } from '$lib/stores/toast.svelte.js'; + +beforeEach(() => { + toasts.length = 0; +}); + +describe('ToastHost', () => { + it('renders nothing when there are no toasts', () => { + render(ToastHost); + expect(page.getByRole('alert').query()).toBeNull(); + }); + + it('renders a toast added before render', () => { + addToast('info', 'Pre-existing toast'); + render(ToastHost); + expect(page.getByText('Pre-existing toast').query()).not.toBeNull(); + }); + + it('labels the dismiss button', () => { + addToast('info', 'Hello'); + render(ToastHost); + const dismissBtn = page.getByRole('button', { name: 'Dismiss' }); + expect(dismissBtn.query()).not.toBeNull(); + }); + + it('renders multiple pre-existing toasts', () => { + addToast('info', 'First'); + addToast('success', 'Second'); + render(ToastHost); + const alerts = document.querySelectorAll('[role="alert"]'); + expect(alerts.length).toBe(2); + }); + + it('renders action buttons when toast has actions', () => { + addToast('warning', 'With action', 5000, [{ label: 'Undo', onClick: () => {} }]); + render(ToastHost); + const undoBtn = page.getByText('Undo'); + expect(undoBtn.query()).not.toBeNull(); + }); + + it('has aria-live="polite" for screen reader announcements', () => { + render(ToastHost); + const container = document.querySelector('[aria-live="polite"]'); + expect(container).not.toBeNull(); + }); +}); diff --git a/src/lib/components/Tooltip.svelte b/src/lib/components/Tooltip.svelte new file mode 100644 index 00000000..4aaa3f3b --- /dev/null +++ b/src/lib/components/Tooltip.svelte @@ -0,0 +1,51 @@ + + +
+
+ + {text} +
+
diff --git a/src/lib/components/Tooltip.svelte.spec.ts b/src/lib/components/Tooltip.svelte.spec.ts new file mode 100644 index 00000000..7fdbffa4 --- /dev/null +++ b/src/lib/components/Tooltip.svelte.spec.ts @@ -0,0 +1,54 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import Tooltip from './Tooltip.svelte'; + +describe('Tooltip', () => { + it('renders tooltip text', async () => { + render(Tooltip, { text: 'Helpful info', x: 100, y: 200 }); + const tooltip = page.getByText('Helpful info'); + await expect.element(tooltip).toBeInTheDocument(); + }); + + it('has role="tooltip"', async () => { + render(Tooltip, { text: 'Info', x: 0, y: 0 }); + const tooltip = page.getByRole('tooltip'); + await expect.element(tooltip).toBeInTheDocument(); + }); + + it('positions based on right orientation (default)', async () => { + render(Tooltip, { text: 'Right', x: 50, y: 100 }); + const tooltip = page.getByRole('tooltip'); + const el = tooltip.element(); + const style = el.getAttribute('style'); + expect(style).toContain('left: 56px'); + expect(style).toContain('top: 100px'); + }); + + it('positions based on left orientation', async () => { + render(Tooltip, { text: 'Left', x: 50, y: 100, orientation: 'left' }); + const tooltip = page.getByRole('tooltip'); + const el = tooltip.element(); + const style = el.getAttribute('style'); + expect(style).toContain('left: 44px'); + expect(style).toContain('top: 100px'); + }); + + it('positions based on up orientation', async () => { + render(Tooltip, { text: 'Up', x: 50, y: 100, orientation: 'up' }); + const tooltip = page.getByRole('tooltip'); + const el = tooltip.element(); + const style = el.getAttribute('style'); + expect(style).toContain('left: 50px'); + expect(style).toContain('top: 94px'); + }); + + it('positions based on down orientation', async () => { + render(Tooltip, { text: 'Down', x: 50, y: 100, orientation: 'down' }); + const tooltip = page.getByRole('tooltip'); + const el = tooltip.element(); + const style = el.getAttribute('style'); + expect(style).toContain('left: 50px'); + expect(style).toContain('top: 106px'); + }); +}); diff --git a/src/lib/components/TooltipTrigger.svelte b/src/lib/components/TooltipTrigger.svelte new file mode 100644 index 00000000..5e20a797 --- /dev/null +++ b/src/lib/components/TooltipTrigger.svelte @@ -0,0 +1,118 @@ + + + text && show()} + onmouseleave={hide} + onfocusin={() => text && show()} + onfocusout={hide} +> + {@render children()} + diff --git a/src/lib/components/TooltipTrigger.svelte.spec.ts b/src/lib/components/TooltipTrigger.svelte.spec.ts new file mode 100644 index 00000000..2f5e1b3b --- /dev/null +++ b/src/lib/components/TooltipTrigger.svelte.spec.ts @@ -0,0 +1,74 @@ +import { page, userEvent } from 'vitest/browser'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import TooltipTriggerWrapper from './__tests__/TooltipTriggerWrapper.svelte'; +import TooltipTriggerDialogWrapper from './__tests__/TooltipTriggerDialogWrapper.svelte'; + +describe('TooltipTrigger', () => { + it('renders the trigger content without a tooltip', async () => { + render(TooltipTriggerWrapper, { text: 'Delete' }); + await expect.element(page.getByRole('button', { name: 'Trigger' })).toBeInTheDocument(); + expect(page.getByRole('tooltip').query()).toBeNull(); + }); + + it('shows the tooltip when the trigger is hovered', async () => { + render(TooltipTriggerWrapper, { text: 'Delete' }); + await page.getByRole('button', { name: 'Trigger' }).hover(); + const tooltip = page.getByRole('tooltip'); + await expect.element(tooltip).toBeInTheDocument(); + await expect.element(tooltip).toHaveTextContent('Delete'); + }); + + it('hides the tooltip when the pointer leaves the trigger', async () => { + render(TooltipTriggerWrapper, { text: 'Delete' }); + await page.getByRole('button', { name: 'Trigger' }).hover(); + await expect.element(page.getByRole('tooltip')).toBeInTheDocument(); + await userEvent.unhover(await page.getByRole('button', { name: 'Trigger' }).element()); + await expect.element(page.getByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('shows the tooltip when the trigger receives focus', async () => { + render(TooltipTriggerWrapper, { text: 'Delete' }); + const button = page.getByRole('button', { name: 'Trigger' }); + (await button.element()).focus(); + await expect.element(page.getByRole('tooltip')).toBeInTheDocument(); + }); + + it('hides the tooltip when the trigger loses focus', async () => { + render(TooltipTriggerWrapper, { text: 'Delete' }); + const button = page.getByRole('button', { name: 'Trigger' }); + (await button.element()).focus(); + await expect.element(page.getByRole('tooltip')).toBeInTheDocument(); + (await button.element()).blur(); + await expect.element(page.getByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('does not show a tooltip when no text is supplied', async () => { + render(TooltipTriggerWrapper, {}); + await page.getByRole('button', { name: 'Trigger' }).hover(); + expect(page.getByRole('tooltip').query()).toBeNull(); + }); + + it('renders the tooltip in a portal attached to , not inline in the trigger', async () => { + render(TooltipTriggerWrapper, { text: 'Delete' }); + await page.getByRole('button', { name: 'Trigger' }).hover(); + const tooltipEl = (await page.getByRole('tooltip').element()) as HTMLElement; + const triggerEl = (await page + .getByRole('button', { name: 'Trigger' }) + .element()) as HTMLElement; + // The popup is portaled: it must not be a descendant of the trigger wrapper. + expect(triggerEl.contains(tooltipEl)).toBe(false); + // It is mounted into a host div that is a direct child of . + expect(tooltipEl.parentElement?.parentElement).toBe(document.body); + }); + + it('portals the tooltip into a ancestor so it escapes the modal', async () => { + render(TooltipTriggerDialogWrapper, { text: 'Delete' }); + const dialogEl = document.querySelector('dialog'); + expect(dialogEl).not.toBeNull(); + await page.getByRole('button', { name: 'Trigger' }).hover(); + const tooltipEl = (await page.getByRole('tooltip').element()) as HTMLElement; + // The popup must be inside the dialog (top layer), not attached to . + expect(dialogEl!.contains(tooltipEl)).toBe(true); + }); +}); diff --git a/src/lib/components/__tests__/TooltipTriggerDialogWrapper.svelte b/src/lib/components/__tests__/TooltipTriggerDialogWrapper.svelte new file mode 100644 index 00000000..117e36e3 --- /dev/null +++ b/src/lib/components/__tests__/TooltipTriggerDialogWrapper.svelte @@ -0,0 +1,18 @@ + + + + + + + diff --git a/src/lib/components/__tests__/TooltipTriggerWrapper.svelte b/src/lib/components/__tests__/TooltipTriggerWrapper.svelte new file mode 100644 index 00000000..c1c37203 --- /dev/null +++ b/src/lib/components/__tests__/TooltipTriggerWrapper.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/lib/components/catalog/CatalogBrowser.svelte b/src/lib/components/catalog/CatalogBrowser.svelte index ba9e765a..565df01e 100644 --- a/src/lib/components/catalog/CatalogBrowser.svelte +++ b/src/lib/components/catalog/CatalogBrowser.svelte @@ -194,7 +194,7 @@ }) .catch((err) => { // Leave the dropdown empty rather than throwing an unhandled rejection. - console.error('Failed to load schemas for context selector', err); + void err; availableSchemas = []; }); } @@ -258,27 +258,28 @@
{m.trino_catalog_browser()} - + + +
{#if catalogsLoading} diff --git a/src/lib/components/editor/MonacoEditor.svelte b/src/lib/components/editor/MonacoEditor.svelte index 65a57c93..64b64d75 100644 --- a/src/lib/components/editor/MonacoEditor.svelte +++ b/src/lib/components/editor/MonacoEditor.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { browser } from '$app/environment'; import { theme } from '$lib/theme.svelte'; + import { getLocale } from '$lib/paraglide/runtime.js'; import { registerTrinoSql, setCompletionDefaultsGetter, @@ -133,7 +134,6 @@ // Start loading in parallel with the rest of the page — not deferred to onMount. // Guarded by `browser` because SvelteKit evaluates component scripts on the server too. const workerImport = browser ? import('monaco-editor/esm/vs/editor/editor.worker?worker') : null; - const monacoImport = browser ? import('monaco-editor') : null; function toMonacoTheme(t: string): string { return t === 'dark' ? 'vs-dark' : 'vs'; @@ -143,14 +143,23 @@ monaco?.editor.setTheme(toMonacoTheme(theme.current)); }); + const cleanups: (() => void)[] = []; + onMount(async () => { + // Load Monaco's NLS bundle for the active locale. Static import strings are + // required — Vite cannot bundle bare-specifier template literals at build time. + if (getLocale() === 'de') { + // @ts-expect-error — Monaco ESM nls bundle has no types + await import('monaco-editor/esm/nls.messages.de.js'); + } + // By the time onMount fires the imports are likely already resolved. const EditorWorker = (await workerImport!).default; self.MonacoEnvironment = { getWorker: () => new EditorWorker() }; - monaco = await monacoImport!; + monaco = await import('monaco-editor'); setCompletionDefaultsGetter(() => ({ catalog: defaultCatalog || undefined, @@ -227,9 +236,86 @@ run: onExecuteAll }); } + + // Ctrl+Mouse Wheel — zoom via built-in command (capture phase on document, + // because Monaco's internal handler calls stopPropagation on wheel events, + // so a container-level listener never fires) + const onWheel = (e: WheelEvent) => { + if ((e.ctrlKey || e.metaKey) && container.contains(e.target as Node)) { + e.preventDefault(); + editor?.trigger( + 'keyboard', + e.deltaY < 0 ? 'editor.action.fontZoomIn' : 'editor.action.fontZoomOut', + {} + ); + } + }; + document.addEventListener('wheel', onWheel, { capture: true, passive: false }); + cleanups.push(() => document.removeEventListener('wheel', onWheel, { capture: true })); + + // Prevent browser zoom & invoke font zoom for any keys Monaco doesn't handle + const handleZoomKeys = (e: KeyboardEvent) => { + if (!editor?.hasTextFocus()) return; + if (!(e.ctrlKey || e.metaKey)) return; + let command: string | null = null; + if (e.key === '=' || e.key === '+' || e.code === 'NumpadAdd') { + command = 'editor.action.fontZoomIn'; + } else if (e.key === '-' || e.code === 'NumpadSubtract') { + command = 'editor.action.fontZoomOut'; + } else if (e.key === '0' || e.code === 'Numpad0') { + command = 'editor.action.fontZoomReset'; + } + if (command) { + if (!e.defaultPrevented) editor?.trigger('keyboard', command, {}); + e.preventDefault(); + } + }; + document.addEventListener('keydown', handleZoomKeys); + cleanups.push(() => document.removeEventListener('keydown', handleZoomKeys)); + + // Bind keyboard shortcuts to the built-in font zoom commands + const zoomDisposable = monaco!.editor.addKeybindingRules([ + { + keybinding: monaco!.KeyMod.CtrlCmd | monaco!.KeyCode.Equal, + command: 'editor.action.fontZoomIn', + when: 'editorFocus' + }, + { + keybinding: monaco!.KeyMod.CtrlCmd | monaco!.KeyMod.Shift | monaco!.KeyCode.Equal, + command: 'editor.action.fontZoomIn', + when: 'editorFocus' + }, + { + keybinding: monaco!.KeyMod.CtrlCmd | monaco!.KeyCode.NumpadAdd, + command: 'editor.action.fontZoomIn', + when: 'editorFocus' + }, + { + keybinding: monaco!.KeyMod.CtrlCmd | monaco!.KeyCode.Minus, + command: 'editor.action.fontZoomOut', + when: 'editorFocus' + }, + { + keybinding: monaco!.KeyMod.CtrlCmd | monaco!.KeyCode.NumpadSubtract, + command: 'editor.action.fontZoomOut', + when: 'editorFocus' + }, + { + keybinding: monaco!.KeyMod.CtrlCmd | monaco!.KeyCode.Digit0, + command: 'editor.action.fontZoomReset', + when: 'editorFocus' + }, + { + keybinding: monaco!.KeyMod.CtrlCmd | monaco!.KeyCode.Numpad0, + command: 'editor.action.fontZoomReset', + when: 'editorFocus' + } + ]); + cleanups.push(() => zoomDisposable.dispose()); }); onDestroy(() => { + cleanups.forEach((fn) => fn()); editor?.dispose(); }); diff --git a/src/lib/components/editor/TextEditor.svelte b/src/lib/components/editor/TextEditor.svelte new file mode 100644 index 00000000..915a116d --- /dev/null +++ b/src/lib/components/editor/TextEditor.svelte @@ -0,0 +1,306 @@ + + +
+ {#if !browser || !ready} +
{displayValue}
+ {/if} +
diff --git a/src/lib/components/editor/TextEditor.svelte.spec.ts b/src/lib/components/editor/TextEditor.svelte.spec.ts new file mode 100644 index 00000000..c23c1e99 --- /dev/null +++ b/src/lib/components/editor/TextEditor.svelte.spec.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import * as m from '$lib/paraglide/messages.js'; +import { getLocale, setLocale } from '$lib/paraglide/runtime.js'; + +// Monaco is replaced by a minimal stub so the test only checks how +// TextEditor registers its save action, not Monaco itself. +const addAction = vi.hoisted(() => vi.fn()); + +vi.mock('monaco-editor/esm/vs/editor/editor.worker?worker', () => ({ + default: class {} +})); + +vi.mock('monaco-editor', () => ({ + KeyMod: { CtrlCmd: 1 << 11, Shift: 1 << 10 }, + KeyCode: { + KeyS: 49, + Equal: 86, + Minus: 88, + NumpadAdd: 109, + NumpadSubtract: 111, + Digit0: 21, + Numpad0: 98 + }, + editor: { + setTheme: vi.fn(), + addKeybindingRules: vi.fn(() => ({ dispose: vi.fn() })), + create: vi.fn(() => ({ + focus: vi.fn(), + onDidChangeModelContent: vi.fn(), + addAction, + getValue: vi.fn(() => ''), + hasTextFocus: vi.fn(() => false), + trigger: vi.fn(), + dispose: vi.fn() + })) + } +})); + +import TextEditor from './TextEditor.svelte'; + +describe('TextEditor', () => { + const initialLocale = getLocale(); + + afterEach(() => { + setLocale(initialLocale, { reload: false }); + addAction.mockClear(); + }); + + it('labels the save action with the translated message', async () => { + render(TextEditor, { value: 'hello', filename: 'a.txt', onSave: () => {} }); + + await vi.waitFor(() => expect(addAction).toHaveBeenCalled()); + expect(addAction).toHaveBeenCalledWith( + expect.objectContaining({ id: 'save-file', label: m.storage_editor_save_action() }) + ); + }); + + it('uses the active locale for the save action label', async () => { + setLocale('de', { reload: false }); + render(TextEditor, { value: 'hallo', filename: 'a.txt', onSave: () => {} }); + + await vi.waitFor(() => expect(addAction).toHaveBeenCalled()); + expect(addAction).toHaveBeenCalledWith( + expect.objectContaining({ id: 'save-file', label: 'Datei speichern' }) + ); + }); +}); diff --git a/src/lib/components/layout/NavigationProgress.svelte b/src/lib/components/layout/NavigationProgress.svelte new file mode 100644 index 00000000..91c202b7 --- /dev/null +++ b/src/lib/components/layout/NavigationProgress.svelte @@ -0,0 +1,40 @@ + + +{#if active} +
+ + {m.navigation_loading()} +
+{/if} diff --git a/src/lib/components/layout/header/LanguageSwitcher.svelte b/src/lib/components/layout/header/LanguageSwitcher.svelte index 445e0044..4288d1f1 100644 --- a/src/lib/components/layout/header/LanguageSwitcher.svelte +++ b/src/lib/components/layout/header/LanguageSwitcher.svelte @@ -26,22 +26,27 @@ } - + +
@@ -129,19 +139,21 @@ diff --git a/src/lib/components/modal/Modal.svelte.spec.ts b/src/lib/components/modal/Modal.svelte.spec.ts new file mode 100644 index 00000000..f9d38a06 --- /dev/null +++ b/src/lib/components/modal/Modal.svelte.spec.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { tick } from 'svelte'; +import { render } from 'vitest-browser-svelte'; +import Modal from '../Modal.svelte'; +import type { Snippet } from 'svelte'; + +function textSnippet(text: string): Snippet { + return (() => text) as unknown as Snippet; +} + +describe('Modal', () => { + it('closes only the topmost dialog on Escape', async () => { + render(Modal, { open: true, children: textSnippet('Outer') }); + render(Modal, { open: true, children: textSnippet('Inner') }); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + await tick(); + const dialogs = [...document.querySelectorAll('dialog')]; + expect(dialogs[0].open).toBe(true); + expect(dialogs[1].open).toBe(false); + }); + + it('closes the most recently opened dialog on Escape, not the last one in the DOM', async () => { + const earlier = render(Modal, { open: false, children: textSnippet('Opened second') }); + render(Modal, { open: true, children: textSnippet('Opened first') }); + await earlier.rerender({ open: true }); + await tick(); + const [openedSecond, openedFirst] = [...document.querySelectorAll('dialog')]; + expect(openedSecond.open).toBe(true); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + await tick(); + + expect(openedSecond.open).toBe(false); + expect(openedFirst.open).toBe(true); + }); + + it('leaves Escape to a widget inside the dialog that handles it', async () => { + render(Modal, { open: true, children: textSnippet('Content') }); + const dialog = document.querySelector('dialog')!; + const widget = document.createElement('input'); + dialog.appendChild(widget); + widget.addEventListener('keydown', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + + widget.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + await tick(); + + expect(dialog.open).toBe(true); + }); + + it('closes on Escape from a Monaco text area that only prevents the default', async () => { + render(Modal, { open: true, children: textSnippet('Content') }); + const dialog = document.querySelector('dialog')!; + const editor = document.createElement('div'); + editor.className = 'monaco-editor'; + const input = document.createElement('textarea'); + editor.appendChild(input); + dialog.appendChild(editor); + input.addEventListener('keydown', (event) => event.preventDefault()); + + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + await tick(); + + expect(dialog.open).toBe(false); + }); + + it('leaves Escape to a Monaco widget that stops propagation', async () => { + render(Modal, { open: true, children: textSnippet('Content') }); + const dialog = document.querySelector('dialog')!; + const editor = document.createElement('div'); + editor.className = 'monaco-editor'; + const findInput = document.createElement('textarea'); + editor.appendChild(findInput); + dialog.appendChild(editor); + findInput.addEventListener('keydown', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + + findInput.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + await tick(); + + expect(dialog.open).toBe(true); + }); + + it('leaves Escape to an open popover inside the dialog', async () => { + render(Modal, { open: true, children: textSnippet('Content') }); + const dialog = document.querySelector('dialog')!; + const popover = document.createElement('div'); + popover.popover = 'auto'; + dialog.appendChild(popover); + popover.showPopover(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + await tick(); + + expect(dialog.open).toBe(true); + }); + + it('respects the close guard on Escape from a Monaco text area', async () => { + render(Modal, { + open: true, + closeguard: () => false, + children: textSnippet('Content') + }); + const dialog = document.querySelector('dialog')!; + const editor = document.createElement('div'); + editor.className = 'monaco-editor'; + const input = document.createElement('textarea'); + editor.appendChild(input); + dialog.appendChild(editor); + input.addEventListener('keydown', (event) => event.preventDefault()); + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true + }); + + input.dispatchEvent(event); + await tick(); + + expect(dialog.open).toBe(true); + expect(event.defaultPrevented).toBe(true); + }); + + it('ignores a close event that arrives after the modal was destroyed', async () => { + let open = true; + const writes: boolean[] = []; + const { unmount } = render(Modal, { + get open() { + return open; + }, + set open(value: boolean) { + writes.push(value); + open = value; + }, + children: textSnippet('Content') + }); + const dialog = document.querySelector('dialog')!; + unmount(); + + // The browser fires `close` in a later task, possibly after a new dialog + // reusing the same binding has opened. + dialog.dispatchEvent(new Event('close')); + await tick(); + + expect(writes).toEqual([]); + }); + + it('sets open to false when the dialog closes itself', async () => { + let open = true; + render(Modal, { + get open() { + return open; + }, + set open(value: boolean) { + open = value; + }, + children: textSnippet('Content') + }); + document.querySelector('dialog')!.dispatchEvent(new Event('close')); + await tick(); + + expect(open).toBe(false); + }); + + it('does not open the dialog when open is false', () => { + render(Modal, { open: false, children: textSnippet('Content') }); + const dialog = document.querySelector('dialog'); + expect(dialog?.open).toBe(false); + }); + + it('opens the dialog when open is true', () => { + render(Modal, { open: true, children: textSnippet('Content') }); + const dialog = document.querySelector('dialog'); + // showModal() does not set the open attribute in the spec, but + // the native open property reflects the open state. + expect(dialog?.open).toBe(true); + }); + + it('renders the dialog element', () => { + render(Modal, { open: true, children: textSnippet('Content') }); + const dialog = document.querySelector('dialog'); + expect(dialog).not.toBeNull(); + }); + + it('applies custom class', () => { + render(Modal, { open: true, class: 'my-custom-class', children: textSnippet('Content') }); + const dialog = document.querySelector('dialog'); + expect(dialog?.className).toContain('my-custom-class'); + }); +}); diff --git a/src/lib/components/popover-position.ts b/src/lib/components/popover-position.ts new file mode 100644 index 00000000..15a6441f --- /dev/null +++ b/src/lib/components/popover-position.ts @@ -0,0 +1,66 @@ +/** + * Feature-detects CSS anchor positioning. DaisyUI's dropdown popover styles + * rely on `position-area` (formerly `inset-area`); see the + * `@supports not (position-area: bottom)` fallback rule in dropdown.css. + */ +export function supportsAnchorPositioning(): boolean { + return typeof CSS !== 'undefined' && CSS.supports('position-area: bottom'); +} + +const GAP = 8; + +/** + * Positions a popover beneath its trigger, flipping above when there isn't + * enough room below and clamping it inside the viewport. Popovers drift to the + * top-left corner of the viewport in browsers without anchor-positioning + * support, so this replaces the CSS `position-area`/`position-anchor` combo + * there and keeps the dropdown anchored to its trigger as expected. + */ +export function positionPopoverRelativeToTrigger( + trigger: HTMLElement, + popover: HTMLElement, + options: { align?: 'start' | 'end'; matchWidth?: boolean } = {} +): void { + const { align = 'start', matchWidth = false } = options; + const triggerRect = trigger.getBoundingClientRect(); + popover.style.maxWidth = `${Math.max(0, window.innerWidth - 2 * GAP)}px`; + popover.style.maxHeight = `${Math.max(0, window.innerHeight - 2 * GAP)}px`; + popover.style.overflowY = 'auto'; + const popoverWidth = matchWidth ? triggerRect.width : popover.offsetWidth; + const popoverHeight = popover.offsetHeight; + + let top = triggerRect.bottom + GAP; + if (top + popoverHeight > window.innerHeight - GAP) { + top = Math.max(GAP, triggerRect.top - popoverHeight - GAP); + } + + const naturalLeft = align === 'end' ? triggerRect.right - popoverWidth : triggerRect.left; + const left = Math.max(GAP, Math.min(naturalLeft, window.innerWidth - popoverWidth - GAP)); + + popover.style.position = 'fixed'; + popover.style.inset = 'auto'; + popover.style.margin = '0'; + popover.style.top = `${top}px`; + popover.style.left = `${left}px`; + if (matchWidth) popover.style.width = `${triggerRect.width}px`; +} + +/** Keep the fallback attached as scrolling, resizing or content changes move it. */ +export function trackPopover( + trigger: HTMLElement, + popover: HTMLElement, + options: { align?: 'start' | 'end'; matchWidth?: boolean } = {} +): () => void { + const update = () => positionPopoverRelativeToTrigger(trigger, popover, options); + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + const observer = new ResizeObserver(update); + observer.observe(trigger); + observer.observe(popover); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + observer.disconnect(); + }; +} diff --git a/src/lib/components/storage/BucketSelect.svelte b/src/lib/components/storage/BucketSelect.svelte new file mode 100644 index 00000000..982ea0d6 --- /dev/null +++ b/src/lib/components/storage/BucketSelect.svelte @@ -0,0 +1,180 @@ + + +
+ + + +
diff --git a/src/lib/components/storage/SearchFilterRow.svelte b/src/lib/components/storage/SearchFilterRow.svelte new file mode 100644 index 00000000..1df7fe2f --- /dev/null +++ b/src/lib/components/storage/SearchFilterRow.svelte @@ -0,0 +1,116 @@ + + +
+
+
+ + +
+ +
+ + + + + {#if filter.field === 'size'} + + onupdate({ value: event.currentTarget.value })} + placeholder={m.storage_search_filter_size_placeholder()} + aria-invalid={hasError || undefined} + aria-describedby={hasError ? errorId : undefined} + /> + {:else} + + onupdate({ value })} + /> + {/if} +
+ + +
+ {#if hasError} +

+ {m.storage_search_filter_invalid()} +

+ {/if} +
diff --git a/src/lib/components/storage/StorageSearch.svelte b/src/lib/components/storage/StorageSearch.svelte new file mode 100644 index 00000000..475fae85 --- /dev/null +++ b/src/lib/components/storage/StorageSearch.svelte @@ -0,0 +1,221 @@ + + + + + + + + + diff --git a/src/lib/components/storage/StorageSearchForm.svelte b/src/lib/components/storage/StorageSearchForm.svelte new file mode 100644 index 00000000..289e0945 --- /dev/null +++ b/src/lib/components/storage/StorageSearchForm.svelte @@ -0,0 +1,260 @@ + + +
{ + event.preventDefault(); + if (!depthValid) return; + void searchState.run(session.id); + }} +> +
+
+
+ + {#if session.status === 'running'} + + {:else} + + {/if} +
+ + {#if !regexValid} + + {/if} + +
+
+ {m.storage_search_scope_label()} + +
+
+ +
(searchState.advancedOpen = event.currentTarget.open)} + > + {m.storage_search_advanced_options()} +
+
+ {m.storage_search_filters_label()} +
+ {#each session.filters as filter (filter.id)} + updateFilter(filter.id, patch)} + onremove={() => searchState.removeFilter(filter.id)} + /> + {/each} +
+ +
+
+ +
+ {#each session.excludePatterns as pattern (pattern)}{pattern}{/each} (searchState.excludeInput = event.currentTarget.value)} + placeholder={m.storage_search_exclude_placeholder()} + onkeydown={(event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + searchState.addExcludePattern(); + } + }} + /> +
+
+
+
+ update({ searchPath: event.currentTarget.value })} + placeholder={m.storage_search_path_placeholder()} + /> +
+
+ { + const value = event.currentTarget.valueAsNumber; + depthBadInput = event.currentTarget.validity.badInput; + update({ maxDepth: Number.isNaN(value) ? undefined : value }); + }} + placeholder={m.storage_search_depth_placeholder()} + aria-invalid={!depthValid || undefined} + aria-describedby={depthValid ? undefined : `${id}-depth-error`} + /> + {#if !depthValid} +

+ {m.storage_search_max_depth_invalid()} +

+ {/if} +
+
+
+
+ diff --git a/src/lib/components/storage/StorageSearchHistory.svelte b/src/lib/components/storage/StorageSearchHistory.svelte new file mode 100644 index 00000000..71e88750 --- /dev/null +++ b/src/lib/components/storage/StorageSearchHistory.svelte @@ -0,0 +1,69 @@ + + +
+
+

{m.storage_search_recent_heading()}

+ {#if entries.length > 0} + + {/if} +
+ + {#if entries.length === 0} +

{m.storage_search_recent_empty()}

+ {:else} +
    + {#each entries as entry (JSON.stringify( [entry.buckets, entry.query, entry.useRegex, entry.excludePatterns, entry.searchPath, entry.maxDepth] ))} +
  • + onUse(e, buckets)} /> +
  • + {/each} +
+ {/if} +
+ + + + diff --git a/src/lib/components/storage/StorageSearchHistoryEntry.svelte b/src/lib/components/storage/StorageSearchHistoryEntry.svelte new file mode 100644 index 00000000..2296df6d --- /dev/null +++ b/src/lib/components/storage/StorageSearchHistoryEntry.svelte @@ -0,0 +1,165 @@ + + +
+
+ + {#if multipleBuckets} + + + + {/if} +
+ {#if multipleBuckets && expanded} +
+ +
    + {#each entry.buckets as bucket (bucket)} + {@const checked = selected.includes(bucket)} +
  • + + + +
  • + {/each} +
+ +
+ {/if} +
diff --git a/src/lib/components/storage/StorageSearchResults.svelte b/src/lib/components/storage/StorageSearchResults.svelte new file mode 100644 index 00000000..ee303858 --- /dev/null +++ b/src/lib/components/storage/StorageSearchResults.svelte @@ -0,0 +1,241 @@ + + +{#snippet SortIcon(key: SortKey)} + {#if sortKey === key} + {#if sortDir === 'asc'} +
+ + {#each paginatedResults as result (result.bucket + result.key)} onOpen(result)} + >{/each} +
{m.storage_search_result_type()}
{#if result.isDirectory}{:else}{/if}{result.bucket}{result.isDirectory ? '-' : formatFileSize(result.size)}
+
+
+ changePage(0)} + onprev={() => changePage(currentPage - 1)} + onnext={() => changePage(currentPage + 1)} + onlast={() => changePage(totalPages - 1)} + onpagesizechange={changePageSize} + /> +
+ {/if} + +{/if} diff --git a/src/lib/components/storage/StorageSearchSessions.svelte b/src/lib/components/storage/StorageSearchSessions.svelte new file mode 100644 index 00000000..967effb6 --- /dev/null +++ b/src/lib/components/storage/StorageSearchSessions.svelte @@ -0,0 +1,60 @@ + + +{#if sessions.length > 1} + +{/if} diff --git a/src/lib/components/storage/__tests__/StorageSearch.svelte.spec.ts b/src/lib/components/storage/__tests__/StorageSearch.svelte.spec.ts new file mode 100644 index 00000000..50880a49 --- /dev/null +++ b/src/lib/components/storage/__tests__/StorageSearch.svelte.spec.ts @@ -0,0 +1,428 @@ +import { page, userEvent } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import StorageSearchWrapper from './StorageSearchWrapper.svelte'; +import { StorageState } from '$lib/storage/state.svelte.js'; +import type { StorageApi } from '$lib/storage/api.js'; +import { StorageError } from '$lib/storage/errors.js'; +import type { RecentSearchEntry, StorageSearchResponse } from '$lib/storage/types.js'; + +const { goto, invalidateAll } = vi.hoisted(() => ({ goto: vi.fn(), invalidateAll: vi.fn() })); + +vi.mock('$app/navigation', () => ({ goto, invalidateAll })); +vi.mock('$app/paths', () => ({ + resolve: (_route: string, params: { connection: string; bucket: string; prefix: string }) => + `/storage/browse/${params.connection}/${params.bucket}/${params.prefix}` +})); + +type SearchApiMock = { + search: ReturnType; + listRecentSearches: ReturnType; + recordRecentSearch: ReturnType; + clearRecentSearches: ReturnType; +}; + +function createState(options?: { + response?: StorageSearchResponse; + history?: RecentSearchEntry[]; +}): { + state: StorageState; + api: SearchApiMock; +} { + const api = { + search: vi.fn().mockResolvedValue(options?.response ?? { results: [] }), + listRecentSearches: vi.fn().mockResolvedValue(options?.history ?? []), + recordRecentSearch: vi.fn().mockResolvedValue(undefined), + clearRecentSearches: vi.fn().mockResolvedValue(undefined) + } satisfies SearchApiMock; + return { + state: new StorageState({ + connected: true, + buckets: ['alpha', 'beta'], + api: api as unknown as StorageApi + }), + api + }; +} + +describe('StorageSearch', () => { + it('opens with the explorer bucket selected', async () => { + const explorer = createState(); + render(StorageSearchWrapper, { state: explorer.state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + await expect.element(page.getByRole('button', { name: 'alpha', exact: true })).toBeVisible(); + }); + + it('loads recent searches when the search modal opens', async () => { + const { state, api } = createState(); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await expect.poll(() => api.listRecentSearches.mock.calls.length).toBe(1); + }); + + it('cycles query text through recent searches without changing search options', async () => { + const { state } = createState({ + history: [ + { + buckets: ['beta'], + query: 'newest', + useRegex: true, + excludePatterns: ['_temp'], + searchPath: 'archive/', + maxDepth: 2 + }, + { + buckets: ['alpha'], + query: 'older', + useRegex: false, + excludePatterns: [], + searchPath: '', + maxDepth: null + } + ] + }); + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + const query = page.getByLabelText('Search query'); + await query.fill('draft'); + await userEvent.keyboard('{ArrowUp}'); + await expect.element(query).toHaveValue('newest'); + await userEvent.keyboard('{ArrowUp}'); + await expect.element(query).toHaveValue('older'); + await userEvent.keyboard('{ArrowUp}'); + await expect.element(query).toHaveValue('older'); + await userEvent.keyboard('{ArrowDown}'); + await expect.element(query).toHaveValue('newest'); + await userEvent.keyboard('{ArrowDown}'); + await expect.element(query).toHaveValue('draft'); + await userEvent.keyboard('{ArrowDown}'); + await expect.element(query).toHaveValue('draft'); + await expect.element(page.getByRole('button', { name: 'alpha', exact: true })).toBeVisible(); + await expect + .element(page.getByRole('button', { name: 'Regular expression search', exact: true })) + .toHaveAttribute('aria-pressed', 'false'); + }); + + it('searches every bucket from the landing page', async () => { + const { state, api } = createState(); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByLabelText('Search query').fill('report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect.poll(() => api.search.mock.calls.length).toBe(2); + expect(api.search).toHaveBeenCalledWith( + expect.objectContaining({ bucket: 'alpha', query: 'report' }) + ); + expect(api.search).toHaveBeenCalledWith( + expect.objectContaining({ bucket: 'beta', query: 'report' }) + ); + }); + + it('retains results and identifies buckets that fail during a multi-bucket search', async () => { + const { state, api } = createState(); + api.search.mockImplementation(({ bucket }: { bucket: string }) => { + if (bucket === 'beta') + return Promise.reject(new StorageError('access_denied', 'Access denied')); + return Promise.resolve({ + results: [{ key: 'report.csv', size: 1, lastModified: new Date(), isDirectory: false }] + }); + }); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByLabelText('Search query').fill('report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent('Access to one or more selected buckets was denied.'); + await expect.element(page.getByRole('alert')).not.toHaveTextContent('beta'); + await expect.element(page.getByRole('button', { name: 'report.csv' })).toBeVisible(); + await expect.element(page.getByText('Partial', { exact: true })).toBeVisible(); + }); + + it('adds a parallel session', async () => { + const { state } = createState(); + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('button', { name: 'Parallel search' }).click(); + await expect.element(page.getByRole('navigation', { name: 'Search sessions' })).toBeVisible(); + await expect.element(page.getByRole('button', { name: 'Search 2', exact: true })).toBeVisible(); + }); + + it('rejects a max folder depth of 0 instead of treating it as unlimited', async () => { + const { state, api } = createState(); + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByText('Advanced options').click(); + await page.getByLabelText('Max folder depth').fill('0'); + await expect.element(page.getByText('Enter a whole number from 1 to 20.')).toBeVisible(); + await page.getByLabelText('Search query').fill('report'); + await userEvent.keyboard('{Enter}'); + expect(api.search).not.toHaveBeenCalled(); + }); + + it('rejects non-numeric max folder depth input instead of treating it as unlimited', async () => { + const { state } = createState(); + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByText('Advanced options').click(); + await userEvent.type(page.getByLabelText('Max folder depth'), '1e'); + await expect.element(page.getByText('Enter a whole number from 1 to 20.')).toBeVisible(); + }); + + it('uses the regex button and sends date and size filters', async () => { + const { state, api } = createState(); + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + const regex = page.getByRole('button', { name: 'Regular expression search', exact: true }); + await regex.click(); + await expect.element(regex).toHaveAttribute('aria-pressed', 'true'); + await page.getByText('Advanced options').click(); + await page.getByLabelText('Size filter value').fill('10'); + await page.getByLabelText('Date filter value').fill('15.03.2027'); + await page.getByLabelText('Search query').fill('report-[0-9]+'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect.poll(() => api.search.mock.calls.length).toBe(1); + expect(api.search).toHaveBeenCalledWith( + expect.objectContaining({ + useRegex: true, + filters: [ + { field: 'date', operator: '>', value: '15.03.2027' }, + { field: 'size', operator: '>', value: '10' } + ] + }) + ); + }); + + it('shows an invalid filter value as text tied to the input', async () => { + const { state } = createState(); + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByText('Advanced options').click(); + const size = page.getByLabelText('Size filter value'); + await size.fill('lots'); + const error = page.getByText('Enter a valid filter value'); + await expect.element(error).toBeVisible(); + const errorId = error.element().id; + await expect.element(size).toHaveAttribute('aria-describedby', errorId); + await size.fill('10 MB'); + await expect.element(error).not.toBeInTheDocument(); + await expect.element(size).not.toHaveAttribute('aria-describedby'); + }); + + it('ties an invalid date filter value to its error text', async () => { + const { state } = createState(); + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByText('Advanced options').click(); + const date = page.getByLabelText('Date filter value'); + await date.fill('soon'); + const error = page.getByText('Enter a valid filter value'); + await expect.element(error).toBeVisible(); + await expect.element(date).toHaveAttribute('aria-describedby', error.element().id); + await expect.element(date).toHaveAttribute('aria-invalid', 'true'); + }); + + it('makes the bucket dropdown divider non-interactive', async () => { + const { state } = createState(); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('button', { name: 'all buckets', exact: true }).click(); + const divider = page.getByRole('presentation'); + await expect.element(divider).toHaveClass('pointer-events-none'); + }); + + it('renders results and opens directories or file previews', async () => { + const { state, api } = createState({ + response: { + results: [ + { key: 'reports/', size: 0, lastModified: new Date(), isDirectory: true }, + { key: 'reports/data.csv', size: 1, lastModified: new Date(), isDirectory: false } + ] + } + }); + state.connectionId = 'conn-123'; + state.connectionHostname = 's3.example.com'; + const preview = vi.spyOn(state, 'openModal'); + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByLabelText('Search query').fill('report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect.element(page.getByText('2 results ·')).toBeVisible(); + await page.getByRole('button', { name: /reportsreports\// }).click(); + expect(goto).toHaveBeenCalledWith('/storage/browse/s3.example.com/alpha/reports'); + + render(StorageSearchWrapper, { state, currentBucket: 'alpha' }); + await page.getByRole('button', { name: 'Open search' }).last().click(); + await page.getByLabelText('Search query').last().fill('data'); + await page.getByRole('button', { name: 'Search', exact: true }).last().click(); + await page.getByRole('button', { name: 'data.csv' }).click(); + expect(preview).toHaveBeenCalledWith('preview', { key: 'reports/data.csv', bucket: 'alpha' }); + expect(goto).toHaveBeenLastCalledWith('/storage/browse/s3.example.com/alpha/reports'); + expect(api.search).toHaveBeenCalled(); + }); + + it('records submitted searches to the history as one grouped entry per search', async () => { + const { state, api } = createState(); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByLabelText('Search query').fill('report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect.poll(() => api.recordRecentSearch.mock.calls.length).toBe(1); + expect(api.recordRecentSearch).toHaveBeenCalledWith( + expect.objectContaining({ buckets: ['alpha', 'beta'], query: 'report' }) + ); + }); + + it('shows an empty state for recent searches', async () => { + const { state } = createState(); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('tab', { name: 'Recent' }).click(); + await expect + .element(page.getByText('Your recent searches will appear here.')) + .toBeInTheDocument(); + }); + + it('lists recent searches and fills the active session when used', async () => { + const { state } = createState({ + history: [ + { + buckets: ['alpha'], + query: 'report', + useRegex: true, + excludePatterns: ['_temp'], + searchPath: 'events/', + maxDepth: 2 + } + ] + }); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('tab', { name: 'Recent' }).click(); + await page.getByRole('button', { name: /report/ }).click(); + await expect.element(page.getByLabelText('Search query')).toHaveValue('report'); + await expect.element(page.getByRole('button', { name: 'alpha', exact: true })).toBeVisible(); + }); + + it('renders grouped multi-bucket history entries without a keyed-each crash', async () => { + const { state } = createState({ + history: [ + { + buckets: ['stackable-ui-test'], + query: 'md', + useRegex: false, + excludePatterns: [], + searchPath: '', + maxDepth: null + }, + { + buckets: ['stackable-ui-test', 'archive'], + query: 'md', + useRegex: false, + excludePatterns: [], + searchPath: '', + maxDepth: null + } + ] + }); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('tab', { name: 'Recent' }).click(); + const entries = await page.getByRole('button', { name: /md/ }).elements(); + expect(entries).toHaveLength(2); + }); + + it('selects specific buckets from a grouped recent entry', async () => { + const { state } = createState({ + history: [ + { + buckets: ['alpha', 'beta'], + query: 'report', + useRegex: false, + excludePatterns: [], + searchPath: '', + maxDepth: null + } + ] + }); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('tab', { name: 'Recent' }).click(); + await page.getByRole('button', { name: 'Choose buckets' }).click(); + await page.getByLabelText('Include beta in this search').click(); + await page.getByRole('button', { name: 'Search selected buckets' }).click(); + await expect.element(page.getByLabelText('Search query')).toHaveValue('report'); + await expect.element(page.getByRole('button', { name: 'beta', exact: true })).toBeVisible(); + }); + + it('opens the bucket collapsible instead of searching when a grouped entry row is clicked', async () => { + const { state, api } = createState({ + history: [ + { + buckets: ['alpha', 'beta'], + query: 'report', + useRegex: false, + excludePatterns: [], + searchPath: '', + maxDepth: null + } + ] + }); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('tab', { name: 'Recent' }).click(); + await page.getByRole('button', { name: /report/ }).click(); + await expect.element(page.getByLabelText('Include beta in this search')).toBeVisible(); + expect(api.search).not.toHaveBeenCalled(); + }); + + it('shows tooltips on the bucket chooser and bucket checkboxes in recent entries', async () => { + const { state } = createState({ + history: [ + { + buckets: ['alpha', 'beta'], + query: 'report', + useRegex: false, + excludePatterns: [], + searchPath: '', + maxDepth: null + } + ] + }); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('tab', { name: 'Recent' }).click(); + + await page.getByRole('button', { name: 'Choose buckets' }).hover(); + await expect.element(page.getByRole('tooltip').last()).toHaveTextContent('Choose buckets'); + + await page.getByRole('button', { name: 'Choose buckets' }).click(); + await page.getByLabelText('Include alpha in this search').hover(); + await expect.element(page.getByRole('tooltip').last()).toHaveTextContent('alpha'); + }); + + it('clears recent searches', async () => { + const { state, api } = createState({ + history: [ + { + buckets: ['alpha'], + query: 'report', + useRegex: false, + excludePatterns: [], + searchPath: '', + maxDepth: null + } + ] + }); + render(StorageSearchWrapper, { state }); + await page.getByRole('button', { name: 'Open search' }).click(); + await page.getByRole('tab', { name: 'Recent' }).click(); + await page.getByRole('button', { name: 'Clear recent searches' }).click(); + await page.getByRole('button', { name: 'Clear', exact: true }).click(); + expect(api.clearRecentSearches).toHaveBeenCalled(); + await expect + .element(page.getByText('Your recent searches will appear here.')) + .toBeInTheDocument(); + }); +}); diff --git a/src/lib/components/storage/__tests__/StorageSearchWrapper.svelte b/src/lib/components/storage/__tests__/StorageSearchWrapper.svelte new file mode 100644 index 00000000..1b182de2 --- /dev/null +++ b/src/lib/components/storage/__tests__/StorageSearchWrapper.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/storage/explorer/ContextMenu.svelte b/src/lib/components/storage/explorer/ContextMenu.svelte index 22a17fc2..08b37ed0 100644 --- a/src/lib/components/storage/explorer/ContextMenu.svelte +++ b/src/lib/components/storage/explorer/ContextMenu.svelte @@ -1,29 +1,35 @@ - +{/if} diff --git a/src/lib/components/storage/explorer/DownloadHistory.svelte b/src/lib/components/storage/explorer/DownloadHistory.svelte new file mode 100644 index 00000000..526849cd --- /dev/null +++ b/src/lib/components/storage/explorer/DownloadHistory.svelte @@ -0,0 +1,137 @@ + + +
+

+ {m.storage_download_history_title()} +

+ {#if entries.length === 0} +

{m.storage_download_history_empty()}

+ {:else} +
    + {#each entries as entry (entry.id)} + {@const chosen = selected[entry.id] ?? []} + {@const files = entry.entries.filter((item) => !item.isDirectory)} + {@const totalSize = entry.entries.reduce((total, item) => total + item.size, 0)} + {@const selectedSize = selectedDownloadHistoryPayloadSize(entry.entries, chosen)} +
  • + + {#if expanded[entry.id]} +
    + + {m.storage_download_history_selected_count({ count: chosen.length })} · {formatFileSize( + selectedSize + )} + + {#each files as item, index (`${entry.id}-${item.key}`)} +
    + toggleKey(entry.id, item.key, event.currentTarget.checked)} + /> + +
    + {/each} +
    +
    + + +
    + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/src/lib/components/storage/explorer/FileExplorer.svelte b/src/lib/components/storage/explorer/FileExplorer.svelte index 615cf043..a62eefdc 100644 --- a/src/lib/components/storage/explorer/FileExplorer.svelte +++ b/src/lib/components/storage/explorer/FileExplorer.svelte @@ -1,16 +1,187 @@ -
+
+ {#if fileDragOver} +
+
+ {/if} + + -
- {#if storage.loading || storage.deleting || navigating.to} +
+ {#if storage.loading || storage.deleting || storage.archive.archiveLoading || navigating.to}
-{#if storage.contextMenu} - -{/if} - - - + storage.closeContextMenu()} + onaction={handleContextMenuAction} + actions={contextMenuActions} + title={m.storage_context_menu_actions()} +/> diff --git a/src/lib/components/storage/explorer/FileRow.svelte b/src/lib/components/storage/explorer/FileRow.svelte index 777475af..519f013e 100644 --- a/src/lib/components/storage/explorer/FileRow.svelte +++ b/src/lib/components/storage/explorer/FileRow.svelte @@ -1,14 +1,13 @@ storage.toggleSelect(file.key, e.ctrlKey || e.metaKey)} - ondblclick={() => storage.executeAction('preview')} + draggable={storageCutCopyEnabled && !storage.archive.isInArchive} + ondragstart={handleDragStart} + onclick={(e) => storage.toggleSelect(file.key, e.ctrlKey || e.metaKey, e.shiftKey)} + ondblclick={() => { + if (isArchive) { + void storage.archive.enterArchive(file.key); + } else { + storage.executeAction('preview'); + } + }} oncontextmenu={(e) => storage.openContextMenu(e, file.key)} > @@ -44,42 +56,25 @@ onchange={() => storage.toggleSelect(file.key, true)} onclick={(e) => e.stopPropagation()} disabled={!storage.showCheckboxes} - aria-label="Select {keyToName(file.key)}" + aria-label={m.storage_select_item({ name: keyToName(file.key) })} /> -
- {#if kind === 'image'} -
+ {formatFileSize(file.size)} - +
+ +
diff --git a/src/lib/components/storage/explorer/FolderRow.svelte b/src/lib/components/storage/explorer/FolderRow.svelte index 5ec0645c..a85cee10 100644 --- a/src/lib/components/storage/explorer/FolderRow.svelte +++ b/src/lib/components/storage/explorer/FolderRow.svelte @@ -4,6 +4,13 @@ import { keyToName } from '$lib/storage/utils.js'; import type { StorageObject } from '$lib/storage/types.js'; import { getStorageState } from '$lib/storage/context.js'; + import { storageCutCopyEnabled } from '$lib/client/feature-flags.js'; + import { + handleRowDragStart, + parseStorageDrop, + canStorageDrop + } from '$lib/storage/drag-handlers.js'; + import * as m from '$lib/paraglide/messages.js'; interface Props { folder: StorageObject; @@ -15,25 +22,70 @@ const selected = $derived(storage.selectedKeys.has(folder.key)); const isCtx = $derived(storage.contextMenu?.key === folder.key); + const isCut = $derived(storage.isCutKey(folder.key)); + + let dragOver = $state(false); + + function handleDragStart(e: DragEvent) { + handleRowDragStart(e, folder.key, storage); + } + + function handleDragOver(e: DragEvent) { + if (!canStorageDrop(storage)) return; + // Don't allow dropping onto a selected folder (moving into itself) + if (storage.selectedKeys.has(folder.key)) return; + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + dragOver = true; + } + + function handleDragLeave() { + dragOver = false; + } + + function handleDrop(e: DragEvent) { + dragOver = false; + if (!canStorageDrop(storage)) return; + e.preventDefault(); + e.stopPropagation(); + const drop = parseStorageDrop(e); + if (!drop) return; + // Don't drop onto a selected folder + if (drop.keys.includes(folder.key)) return; + // Move items into this folder + void storage.performMove(folder.key, drop.keys, undefined, drop.source); + } { if (storage.selectionMode || e.ctrlKey || e.metaKey) { - storage.toggleSelect(folder.key, true); + storage.toggleSelect(folder.key, true, e.shiftKey); + } else if (storage.archive.isInArchive) { + storage.archive.navigateInArchive(folder.key); } else { storage.navigate(folder.key); } }} - ondblclick={(e) => { - if (e.ctrlKey || e.metaKey) { + ondblclick={() => { + if (storage.archive.isInArchive) { + storage.archive.navigateInArchive(folder.key); + } else { storage.navigate(folder.key); } }} @@ -47,25 +99,26 @@ onchange={() => storage.toggleSelect(folder.key, true)} onclick={(e) => e.stopPropagation()} disabled={!storage.showCheckboxes} - aria-label="Select {keyToName(folder.key)}" + aria-label={m.storage_select_item({ name: keyToName(folder.key) })} />
-
— — - +
+ +
diff --git a/src/lib/components/storage/explorer/ObjectTable.svelte b/src/lib/components/storage/explorer/ObjectTable.svelte index 74d9d0c6..103417d0 100644 --- a/src/lib/components/storage/explorer/ObjectTable.svelte +++ b/src/lib/components/storage/explorer/ObjectTable.svelte @@ -2,6 +2,7 @@ import * as m from '$lib/paraglide/messages.js'; import IconArrowBack from 'virtual:icons/material-symbols/arrow-back'; import IconFolderOpen from 'virtual:icons/material-symbols/folder-open'; + import IconWarning from 'virtual:icons/material-symbols/warning'; import SelectionToolbar from './SelectionToolbar.svelte'; import FolderRow from './FolderRow.svelte'; import FileRow from './FileRow.svelte'; @@ -14,17 +15,182 @@ if (selectAllEl) selectAllEl.indeterminate = storage.someSelected; }); + import { parseStorageDrop, canStorageDrop } from '$lib/storage/drag-handlers.js'; + function navigateUp() { + if (storage.archive.isInArchive) { + storage.archive.navigateUpFromArchive(); + return; + } if (!storage.prefix) return; const withoutTrailing = storage.prefix.slice(0, -1); const lastSlash = withoutTrailing.lastIndexOf('/'); storage.navigate(lastSlash === -1 ? '' : withoutTrailing.slice(0, lastSlash + 1)); } + + function parentPrefix(): string { + if (!storage.prefix) return ''; + const withoutTrailing = storage.prefix.slice(0, -1); + const lastSlash = withoutTrailing.lastIndexOf('/'); + return lastSlash === -1 ? '' : withoutTrailing.slice(0, lastSlash + 1); + } + + let tableDragOver = $state(false); + let parentDragOver = $state(false); + let emptyDragOver = $state(false); + + let scrollContainer = $state(null); + let autoScrollAnimFrame = $state(null); + + const EDGE_THICKNESS = 40; + const BASE_SCROLL_SPEED = 3; + const MAX_SCROLL_SPEED = 8; + + let headerEl = $state(null); + + function stopAutoScroll() { + if (autoScrollAnimFrame !== null) { + cancelAnimationFrame(autoScrollAnimFrame); + autoScrollAnimFrame = null; + } + } + + function contentEdgeY(clientY: number): number | null { + const container = scrollContainer; + if (!container) return null; + const bodyTop = headerEl + ? headerEl.getBoundingClientRect().bottom + : container.getBoundingClientRect().top; + return clientY - bodyTop; + } + + function handleTableDragOver(e: DragEvent) { + if (!canStorageDrop(storage)) return; + if (!e.dataTransfer?.types.includes('application/x-storage-keys')) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + tableDragOver = true; + + const container = scrollContainer; + if (!container) return; + const edgeY = contentEdgeY(e.clientY); + if (edgeY === null) return; + const el = container as HTMLElement; + if (edgeY < EDGE_THICKNESS && el.scrollTop > 0) { + const factor = 1 - edgeY / EDGE_THICKNESS; + const speed = BASE_SCROLL_SPEED + (MAX_SCROLL_SPEED - BASE_SCROLL_SPEED) * factor; + stopAutoScroll(); + function tick() { + const newTop = el.scrollTop - speed; + if (newTop <= 0) { + el.scrollTop = 0; + stopAutoScroll(); + return; + } + el.scrollTop = newTop; + autoScrollAnimFrame = requestAnimationFrame(tick); + } + autoScrollAnimFrame = requestAnimationFrame(tick); + } else { + const rect = el.getBoundingClientRect(); + const y = e.clientY - rect.top; + if (y > rect.height - EDGE_THICKNESS && el.scrollTop < el.scrollHeight - el.clientHeight) { + const factor = (y - (rect.height - EDGE_THICKNESS)) / EDGE_THICKNESS; + const speed = BASE_SCROLL_SPEED + (MAX_SCROLL_SPEED - BASE_SCROLL_SPEED) * factor; + stopAutoScroll(); + function tick() { + const newTop = el.scrollTop + speed; + const maxScroll = el.scrollHeight - el.clientHeight; + if (newTop >= maxScroll) { + el.scrollTop = maxScroll; + stopAutoScroll(); + return; + } + el.scrollTop = newTop; + autoScrollAnimFrame = requestAnimationFrame(tick); + } + autoScrollAnimFrame = requestAnimationFrame(tick); + } else { + stopAutoScroll(); + } + } + } + + function handleTableDragLeave(e: DragEvent) { + // Only clear if we're leaving the table container entirely + const related = e.relatedTarget as Node | null; + if (related && (e.currentTarget as HTMLElement).contains(related)) return; + stopAutoScroll(); + tableDragOver = false; + } + + function handleTableDrop(e: DragEvent) { + stopAutoScroll(); + tableDragOver = false; + if (!canStorageDrop(storage)) return; + e.preventDefault(); + const drop = parseStorageDrop(e); + if (!drop) return; + void storage.performMove(storage.prefix, drop.keys, undefined, drop.source); + } + + function handleParentDragOver(e: DragEvent) { + if (!canStorageDrop(storage) || !storage.prefix) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + parentDragOver = true; + } + + function handleParentDragLeave() { + parentDragOver = false; + } + + function handleParentDrop(e: DragEvent) { + parentDragOver = false; + if (!canStorageDrop(storage) || !storage.prefix) return; + e.preventDefault(); + const drop = parseStorageDrop(e); + if (!drop) return; + void storage.performMove(parentPrefix(), drop.keys, undefined, drop.source); + } + + function handleEmptyDragOver(e: DragEvent) { + if (!canStorageDrop(storage)) return; + if (!e.dataTransfer?.types.includes('application/x-storage-keys')) return; + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + emptyDragOver = true; + } + + function handleEmptyDragLeave() { + emptyDragOver = false; + } + + function handleEmptyDrop(e: DragEvent) { + emptyDragOver = false; + if (!canStorageDrop(storage)) return; + e.preventDefault(); + e.stopPropagation(); + const drop = parseStorageDrop(e); + if (!drop) return; + void storage.performMove(storage.prefix, drop.keys, undefined, drop.source); + } -
+
- + @@ -45,7 +211,7 @@ onchange={(e) => storage.selectAll(e.currentTarget.checked)} onclick={(e) => e.stopPropagation()} disabled={!storage.showCheckboxes} - aria-label="Select all" + aria-label={m.storage_select_all()} /> @@ -58,7 +224,15 @@ {#if storage.prefix} - + {/if} - - {#each storage.folders as folder (folder.key)} - - {/each} - - - {#each storage.files as file (file.key)} - - {/each} + {#if storage.loading && storage.folders.length === 0 && storage.files.length === 0} + {#each [75, 60, 85, 45, 90] as width, i (i)} + + + + + + + + {/each} + {/if} - - {#if storage.folders.length === 0 && storage.files.length === 0} + {#if storage.archive.archiveTooLarge} + - + {:else} + + {#each storage.folders as folder (folder.key)} + + {/each} + + + {#each storage.files as file (file.key)} + + {/each} + + + {#if !storage.loading && storage.folders.length === 0 && storage.files.length === 0} + storage.openEmptyContextMenu(e)} + > + + + {/if} {/if}
{m.storage_header_name()}
@@ -70,24 +244,56 @@
- +
+
diff --git a/src/lib/components/storage/explorer/OperationsButton.svelte b/src/lib/components/storage/explorer/OperationsButton.svelte new file mode 100644 index 00000000..5fbc02fd --- /dev/null +++ b/src/lib/components/storage/explorer/OperationsButton.svelte @@ -0,0 +1,499 @@ + + + e.key === 'Escape' && (dropdownOpen = false)} /> + +{#if visibleOps.length > 0 || storage.downloadHistory.length > 0 || storage.downloadHistoryLoading} + {#if dropdownOpen} +
(dropdownOpen = false)} + oncontextmenu={(e) => { + e.preventDefault(); + dropdownOpen = false; + }} + role="presentation" + aria-hidden="true" + >
+ {/if} +
+ +
+ +
+ + + {#if dropdownOpen} +
e.preventDefault()} + aria-label={m.storage_operations_label()} + class="rounded-box border-base-300 bg-base-100 absolute right-0 z-60 mt-2 flex max-h-[calc(100dvh-6rem)] w-[min(24rem,calc(100vw-1rem))] origin-top-right flex-col overflow-hidden border shadow-xl" + > + + {#if activeOps.length > 0} +
+

+ {m.storage_operations_active()} +

+
    + {#each activeOps as op (op.id)} + {@const TypeIcon = typeIconMap[op.type]} +
  • + +
    +
    toggleExpand(op.id)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpand(op.id); + } + }} + aria-expanded={expandedOps[op.id]} + > + + + + + + + + + {op.label} + + + + {#if op.totalBytes > 0} + + {speedEtaLabel(op)} + + {/if} +
    + + +
    + +
    +
    + + + {#if expandedOps[op.id]} +
    + + {#if op.currentFileName} +
    + {op.currentFileName} +
    + {/if} + + + {#if op.totalBytes > 0 || op.itemCount > 1} +
    0 + ? Math.min(displayBytes(op), op.totalBytes) + : op.completedCount} + aria-valuemin={0} + aria-valuemax={op.totalBytes > 0 ? op.totalBytes : op.itemCount} + aria-label={op.label} + > +
    + + {progressLabel(op)} + + + {#if op.totalBytes > 0} + {percent(op)}% + {:else} + {op.completedCount}/{op.itemCount} + {/if} + +
    +
    +
    +
    +
    + {/if} + + + {#if op.sourceNames && op.sourceNames.length > 0} +
    + {#each op.sourceNames as name, i (i)} +
    + {#if name === op.currentFileName} + + {:else if i < op.completedCount} + + {:else} + + {/if} + {name} +
    + {/each} +
    + {/if} + + +
    + {formatElapsed(op.startedAt, op.completedAt)} +
    +
    + {:else} + + {#if op.currentFileName} +
    + {op.currentFileName} +
    + {/if} + {/if} +
  • + {/each} +
+
+ {/if} + + + {#if hasAnyHistory} +
+
+

+ {m.storage_operations_history()} +

+ +
+ {#if hasHistory} +
    + {#each historyOps as op (op.id)} + {@const TypeIcon = typeIconMap[op.type]} +
  • +
    + + + + + + {op.label} + +
    + + {statusLabel(op)} + + + {formatElapsed(op.startedAt, op.completedAt)} + +
    +
    + + + {#if op.status === 'error' && op.errorMessage} +
    + {op.errorMessage} +
    + {:else if op.status === 'interrupted'} +
    + {m.storage_operations_interrupted_tooltip()} +
    + {:else if op.status === 'unknown'} +
    + {m.storage_operations_unknown_tooltip()} +
    + {/if} + + + {#if (op.status === 'interrupted' || op.status === 'unknown' || op.status === 'error') && op.totalBytes > 0} +
    +
    +
    +
    + + {formatBytes(op.completedBytes)} / {formatBytes(op.totalBytes)} + +
    + {/if} +
  • + {/each} +
+ {/if} +
+ {:else if activeOps.length === 0} +
+
+ {/if} + storage.redownloadHistory(id, keys)} + /> +
+ {/if} +
+{/if} diff --git a/src/lib/components/storage/explorer/SelectionToolbar.svelte b/src/lib/components/storage/explorer/SelectionToolbar.svelte index 91dd742f..b30771b2 100644 --- a/src/lib/components/storage/explorer/SelectionToolbar.svelte +++ b/src/lib/components/storage/explorer/SelectionToolbar.svelte @@ -1,10 +1,13 @@ @@ -23,6 +27,11 @@ {m.storage_selected({ count: selectedCount })} + {#if storage.selectedFiles.length > 0} + + {m.storage_selected_files_size({ size: formatFileSize(selectedFileSize) })} + + {/if} -
+ {#if !storage.archive.isInArchive} + + {/if} + +
+ - - -{/if} + {#snippet pinButton(bucket: string, prefix: string)} {@const pinned = storage.bookmarks.isPinned(bucket, prefix)} @@ -102,58 +245,68 @@ motion-safe:transition-[width] motion-safe:duration-150 motion-safe:group-hover:delay-700 " > - + + {/snippet}
@@ -288,61 +562,117 @@ {m.storage_select_toggle()} - - - - - diff --git a/src/lib/components/storage/explorer/TabBar.svelte b/src/lib/components/storage/explorer/TabBar.svelte new file mode 100644 index 00000000..ad5f0572 --- /dev/null +++ b/src/lib/components/storage/explorer/TabBar.svelte @@ -0,0 +1,418 @@ + + + + +{#if tabsState.hasTabs} +
+ +
+ {#each tabsState.tabs as tab, idx (tab.id)} + {@const isActive = tab.id === tabsState.activeTabId} + {#if renamingId === tab.id} +
+ + +
+ {:else} + + {#if tabsState.tabs.length > 1} + + + + {/if} + {/if} + {/each} + + + + + +
+ + + {#if isOverflowLeft} + + {/if} + + + {#if isOverflowRight} + + {/if} +
+{/if} + + diff --git a/src/lib/components/storage/explorer/TabBar.svelte.spec.ts b/src/lib/components/storage/explorer/TabBar.svelte.spec.ts new file mode 100644 index 00000000..7324aad1 --- /dev/null +++ b/src/lib/components/storage/explorer/TabBar.svelte.spec.ts @@ -0,0 +1,211 @@ +import { page, userEvent } from 'vitest/browser'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import TabBarWrapper from './__tests__/TabBarWrapper.svelte'; +import { TabsState } from '$lib/storage/tabs.svelte.js'; +import { StorageState } from '$lib/storage/state.svelte.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeStorage(bucket = 'test-bucket', prefix = ''): StorageState { + const state = new StorageState({ connected: true }); + state.bucket = bucket; + state.prefix = prefix; + state.objects = { objects: [], hasNextPage: false, currentPage: 1, pageSize: 25 }; + return state; +} + +function makeTabsState(storage: StorageState, tabCount = 2): TabsState { + const ts = new TabsState(storage, { persistEnabled: false }); + ts.ensureInitialTab(); + for (let i = 1; i < tabCount; i++) { + ts.addTab(); + } + return ts; +} + +function renderTabBar(storage: StorageState, tabsState: TabsState) { + return render(TabBarWrapper, { storage, tabsState }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('TabBar', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('renders a tab for each entry in tabsState.tabs', async () => { + const storage = makeStorage('bucket-a'); + const tabsState = makeTabsState(storage, 2); + renderTabBar(storage, tabsState); + + const tabs = page.getByRole('tab'); + // 2 tabs rendered + await expect.element(tabs.nth(0)).toBeInTheDocument(); + await expect.element(tabs.nth(1)).toBeInTheDocument(); + }); + + it('marks the active tab with aria-selected="true"', async () => { + const storage = makeStorage('bucket-a'); + const tabsState = makeTabsState(storage, 2); + renderTabBar(storage, tabsState); + + const activeTab = page.getByRole('tab', { selected: true }); + await expect.element(activeTab).toBeInTheDocument(); + }); + + it('has a tablist with an accessible label', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + renderTabBar(storage, tabsState); + + await expect.element(page.getByRole('tablist')).toBeInTheDocument(); + }); + + it('renders a "New Tab" plus button', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + renderTabBar(storage, tabsState); + + await expect.element(page.getByRole('button', { name: 'New Tab' })).toBeInTheDocument(); + }); + + it('calls addTab when the plus button is clicked', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + const spy = vi.spyOn(tabsState, 'addTab'); + renderTabBar(storage, tabsState); + + await page.getByRole('button', { name: 'New Tab' }).click(); + + expect(spy).toHaveBeenCalled(); + }); + + it('calls switchTo with the correct id when a non-active tab is clicked', async () => { + const storage = makeStorage('bucket', ''); + const tabsState = makeTabsState(storage, 2); + // After makeTabsState(2): tabs[0] is the initial tab (not active), tabs[1] is active + const spy = vi.spyOn(tabsState, 'switchTo'); + renderTabBar(storage, tabsState); + + // Click the first tab (not the active one) + await page.getByRole('tab').nth(0).click(); + + expect(spy).toHaveBeenCalledWith(tabsState.tabs[0].id); + }); + + it('shows close buttons when multiple tabs exist', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + renderTabBar(storage, tabsState); + + // Close tab buttons have aria-label="Close tab" + const closeButtons = page.getByRole('button', { name: 'Close tab' }); + await expect.element(closeButtons.first()).toBeInTheDocument(); + }); + + it('calls closeTab when a close button is clicked', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + const spy = vi.spyOn(tabsState, 'closeTab'); + renderTabBar(storage, tabsState); + + await page.getByRole('button', { name: 'Close tab' }).first().click(); + + expect(spy).toHaveBeenCalled(); + }); + + it('opens a context menu on right-click of a tab', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + renderTabBar(storage, tabsState); + + await page.getByRole('tab').first().click({ button: 'right' }); + + await expect.element(page.getByRole('menu')).toBeInTheDocument(); + }); + + it('context menu contains Rename tab and Close tab options', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + renderTabBar(storage, tabsState); + + await page.getByRole('tab').first().click({ button: 'right' }); + + await expect.element(page.getByRole('menuitem', { name: 'Rename tab' })).toBeInTheDocument(); + await expect.element(page.getByRole('menuitem', { name: 'Close tab' })).toBeInTheDocument(); + }); + + it('calls renameTab via context menu rename action', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + const spy = vi.spyOn(tabsState, 'renameTab'); + renderTabBar(storage, tabsState); + + const firstTabId = tabsState.tabs[0].id; + await page.getByRole('tab').first().click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Rename tab' }).click(); + + // The rename input should appear (context menu closes and rename mode starts) + await expect.element(page.getByRole('textbox', { name: 'Rename tab' })).toBeInTheDocument(); + + await page.getByRole('textbox', { name: 'Rename tab' }).fill('Renamed'); + await userEvent.keyboard('{Enter}'); + + expect(spy).toHaveBeenCalledWith(firstTabId, 'Renamed'); + }); + + it('shows rename input on double-click of a tab', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + renderTabBar(storage, tabsState); + + await page.getByRole('tab').first().dblClick(); + + await expect.element(page.getByRole('textbox', { name: 'Rename tab' })).toBeInTheDocument(); + }); + + it('commits rename on Enter key', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + const spy = vi.spyOn(tabsState, 'renameTab'); + renderTabBar(storage, tabsState); + + const firstTabId = tabsState.tabs[0].id; + await page.getByRole('tab').first().dblClick(); + + await page.getByRole('textbox', { name: 'Rename tab' }).fill('My New Name'); + await userEvent.keyboard('{Enter}'); + + expect(spy).toHaveBeenCalledWith(firstTabId, 'My New Name'); + }); + + it('cancels rename on Escape key without calling renameTab', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + const spy = vi.spyOn(tabsState, 'renameTab'); + renderTabBar(storage, tabsState); + + await page.getByRole('tab').first().dblClick(); + + await page.getByRole('textbox', { name: 'Rename tab' }).fill('Something'); + await userEvent.keyboard('{Escape}'); + + expect(spy).not.toHaveBeenCalled(); + await expect.element(page.getByRole('textbox', { name: 'Rename tab' })).not.toBeInTheDocument(); + }); + + it('calls closeTab via context menu close action', async () => { + const storage = makeStorage(); + const tabsState = makeTabsState(storage, 2); + const spy = vi.spyOn(tabsState, 'closeTab'); + const firstTabId = tabsState.tabs[0].id; + renderTabBar(storage, tabsState); + + await page.getByRole('tab').first().click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Close tab' }).click(); + + expect(spy).toHaveBeenCalledWith(firstTabId); + }); +}); diff --git a/src/lib/components/storage/explorer/__tests__/ContextMenu.svelte.spec.ts b/src/lib/components/storage/explorer/__tests__/ContextMenu.svelte.spec.ts index 2e86509d..994ca594 100644 --- a/src/lib/components/storage/explorer/__tests__/ContextMenu.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/ContextMenu.svelte.spec.ts @@ -82,7 +82,7 @@ describe('ContextMenu', () => { await expect.element(previewBtn).toHaveAttribute('disabled'); }); - it('should disable download when context is a folder with no selected files', async () => { + it('should enable download when context is a folder', async () => { const folder = makeFolder('docs/'); const state = createState([folder], { contextMenu: { x: 100, y: 100, key: 'docs/' }, @@ -91,7 +91,7 @@ describe('ContextMenu', () => { render(ContextMenuWrapper, { state }); const downloadBtn = page.getByRole('menuitem', { name: 'Download' }); - await expect.element(downloadBtn).toHaveAttribute('disabled'); + await expect.element(downloadBtn).not.toHaveAttribute('disabled'); }); it('should enable download when context is a file', async () => { @@ -113,7 +113,7 @@ describe('ContextMenu', () => { selectedKeys: ['test.txt'] }); const spy = vi.spyOn(state, 'closeContextMenu'); - render(ContextMenuWrapper, { state }); + render(ContextMenuWrapper, { state, title: 'Menu' }); await page.getByRole('menuitem', { name: 'Close' }).click(); expect(spy).toHaveBeenCalled(); diff --git a/src/lib/components/storage/explorer/__tests__/ContextMenuWrapper.svelte b/src/lib/components/storage/explorer/__tests__/ContextMenuWrapper.svelte index 8307de87..db8496e3 100644 --- a/src/lib/components/storage/explorer/__tests__/ContextMenuWrapper.svelte +++ b/src/lib/components/storage/explorer/__tests__/ContextMenuWrapper.svelte @@ -1,15 +1,51 @@ - + state.closeContextMenu()} + onaction={(key) => state.executeAction(key as 'preview' | 'download' | 'delete')} + {actions} + {title} +/> diff --git a/src/lib/components/storage/explorer/__tests__/DownloadHistory.svelte.spec.ts b/src/lib/components/storage/explorer/__tests__/DownloadHistory.svelte.spec.ts new file mode 100644 index 00000000..71967e3f --- /dev/null +++ b/src/lib/components/storage/explorer/__tests__/DownloadHistory.svelte.spec.ts @@ -0,0 +1,107 @@ +import '../../../../../app.css'; +import { page } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import DownloadHistory from '../DownloadHistory.svelte'; +import type { DownloadHistoryEntry } from '$lib/storage/api.js'; + +function makeEntry(overrides: Partial = {}): DownloadHistoryEntry { + return { + id: 'entry-1', + bucket: 'test-bucket', + connectionId: 'connection-1', + entries: [ + { key: 'reports/one.txt', size: 1024, isDirectory: false }, + { key: 'reports/two.txt', size: 2048, isDirectory: false } + ], + archive: true, + archiveFilename: 'test-bucket.zip', + createdAt: new Date('2026-08-24T10:00:00Z').toISOString(), + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + ...overrides + }; +} + +function renderHistory(entries: DownloadHistoryEntry[], onDownload = vi.fn(async () => {})) { + const { container, unmount } = render(DownloadHistory, { entries, onDownload }); + const section = container.querySelector('section') as HTMLElement; + // Constrain to the operations dropdown panel width. + section.style.width = '384px'; + return { section, onDownload, unmount }; +} + +describe('DownloadHistory', () => { + it('shows the empty state when there are no entries', async () => { + renderHistory([]); + await expect.element(page.getByText('No downloads in the last 30 days.')).toBeInTheDocument(); + }); + + it('renders entry metadata with file count and archive format', async () => { + renderHistory([makeEntry()]); + await expect.element(page.getByText('test-bucket')).toBeInTheDocument(); + await expect.element(page.getByText('2 files')).toBeInTheDocument(); + await expect.element(page.getByText('ZIP')).toBeInTheDocument(); + }); + + it('expands details and reports the number of selected files', async () => { + renderHistory([makeEntry()]); + await page.getByRole('button', { name: /test-bucket/ }).click(); + + const checkboxes = page.getByRole('checkbox'); + await expect.element(checkboxes).toHaveLength(2); + + await page.getByLabelText(/reports\/one\.txt/).click(); + await expect.element(page.getByText('1 selected')).toBeInTheDocument(); + + await page.getByLabelText(/reports\/one\.txt/).click(); + await expect.element(page.getByText('0 selected')).toBeInTheDocument(); + }); + + it('downloads the selected keys via the callback', async () => { + const onDownload = vi.fn(async () => {}); + renderHistory([makeEntry()], onDownload); + await page.getByRole('button', { name: /test-bucket/ }).click(); + await page.getByLabelText(/reports\/two\.txt/).click(); + + await page.getByRole('button', { name: 'Download selected' }).click(); + + await vi.waitFor(() => expect(onDownload).toHaveBeenCalledWith('entry-1', ['reports/two.txt'])); + await expect + .element(page.getByRole('button', { name: 'Download selected' })) + .not.toBeDisabled(); + }); + + it('only lists files in expanded details', async () => { + renderHistory([ + makeEntry({ + entries: [ + { key: 'reports/', size: 0, isDirectory: true }, + { key: 'reports/one.txt', size: 1024, isDirectory: false } + ] + }) + ]); + await page.getByRole('button', { name: /test-bucket/ }).click(); + + await expect.element(page.getByRole('checkbox')).toHaveLength(1); + await expect.element(page.getByLabelText(/reports\/one\.txt/)).toBeInTheDocument(); + await expect.element(page.getByText('reports/', { exact: true })).not.toBeInTheDocument(); + }); + + it('does not overflow horizontally with a long unbreakable object key', async () => { + const longKey = `very-long-object-name-${'x'.repeat(120)}.bin`; + const { section } = renderHistory([ + makeEntry({ entries: [{ key: longKey, size: 123_456, isDirectory: false }] }) + ]); + await page.getByRole('button', { name: /test-bucket/ }).click(); + await expect.element(page.getByText(/0 selected/)).toBeInTheDocument(); + + expect(section.scrollWidth).toBeLessThanOrEqual(section.clientWidth); + }); + + it('does not overflow horizontally with a very long bucket name', async () => { + const { section } = renderHistory([ + makeEntry({ bucket: `extremely-long-bucket-name-${'y'.repeat(120)}` }) + ]); + expect(section.scrollWidth).toBeLessThanOrEqual(section.clientWidth); + }); +}); diff --git a/src/lib/components/storage/explorer/__tests__/FileExplorer.svelte.spec.ts b/src/lib/components/storage/explorer/__tests__/FileExplorer.svelte.spec.ts index 2c31faee..e5fea596 100644 --- a/src/lib/components/storage/explorer/__tests__/FileExplorer.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/FileExplorer.svelte.spec.ts @@ -44,6 +44,79 @@ function createState( } describe('FileExplorer', () => { + it('captures external file drops on folder rows for upload to the current prefix', async () => { + const state = createState([{ ...makeFile('reports/child/'), isDirectory: true }], { + prefix: 'reports/' + }); + const move = vi.spyOn(state, 'performMove'); + render(FileExplorerWrapper, { state }); + await expect.element(page.getByText('child', { exact: true })).toBeInTheDocument(); + + const row = page.getByText('child', { exact: true }).element().closest('tr')!; + const dt = new DataTransfer(); + const file = new File(['report'], 'report.txt', { type: 'text/plain' }); + dt.items.add(file); + row.dispatchEvent(new DragEvent('dragenter', { bubbles: true, dataTransfer: dt })); + await expect.element(page.getByTestId('storage-upload-drop-overlay')).toBeInTheDocument(); + row.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })); + + await expect.poll(() => state.activeModal?.type).toBe('upload'); + expect(state.activeModal).toEqual({ + type: 'upload', + payload: { + bucket: 'test-bucket', + prefix: 'reports/', + files: [{ file, relativePath: 'report.txt' }] + } + }); + expect(move).not.toHaveBeenCalled(); + await expect.element(page.getByTestId('storage-upload-drop-overlay')).not.toBeInTheDocument(); + }); + + it.each(['archive', 'loading', 'modal'])( + 'prevents file navigation without opening an upload while in %s', + async (blocked) => { + const state = createState([makeFile('report.txt')]); + if (blocked === 'archive') state.archive.archiveKey = 'data.zip'; + if (blocked === 'loading') state.loading = true; + if (blocked === 'modal') state.openModal('upload', { bucket: state.bucket, prefix: '' }); + const openModal = vi.spyOn(state, 'openModal'); + render(FileExplorerWrapper, { state }); + await expect.element(page.getByTestId('storage-file-browser')).toBeInTheDocument(); + + const dt = new DataTransfer(); + dt.items.add(new File(['report'], 'report.txt')); + const drop = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }); + page.getByTestId('storage-file-browser').element().dispatchEvent(drop); + expect(drop.defaultPrevented).toBe(true); + expect(openModal).not.toHaveBeenCalled(); + } + ); + + it('ignores text drags and clears the file overlay when leaving the browser', async () => { + const state = createState(); + render(FileExplorerWrapper, { state }); + await expect.element(page.getByTestId('storage-file-browser')).toBeInTheDocument(); + const browser = page.getByTestId('storage-file-browser').element(); + const text = new DataTransfer(); + text.setData('text/plain', 'text'); + const textDrop = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: text + }); + browser.dispatchEvent(textDrop); + expect(textDrop.defaultPrevented).toBe(false); + expect(state.activeModal).toBeNull(); + + const dt = new DataTransfer(); + dt.items.add(new File(['report'], 'report.txt')); + browser.dispatchEvent(new DragEvent('dragenter', { bubbles: true, dataTransfer: dt })); + await expect.element(page.getByTestId('storage-upload-drop-overlay')).toBeInTheDocument(); + browser.dispatchEvent(new DragEvent('dragleave', { bubbles: true, dataTransfer: dt })); + await expect.element(page.getByTestId('storage-upload-drop-overlay')).not.toBeInTheDocument(); + }); + it('should render the object table', async () => { const state = createState([makeFile('hello.txt')]); render(FileExplorerWrapper, { state }); diff --git a/src/lib/components/storage/explorer/__tests__/FileExplorerWrapper.svelte b/src/lib/components/storage/explorer/__tests__/FileExplorerWrapper.svelte index 7106df4f..2c577d6b 100644 --- a/src/lib/components/storage/explorer/__tests__/FileExplorerWrapper.svelte +++ b/src/lib/components/storage/explorer/__tests__/FileExplorerWrapper.svelte @@ -1,7 +1,8 @@ diff --git a/src/lib/components/storage/explorer/__tests__/FileRow.svelte.spec.ts b/src/lib/components/storage/explorer/__tests__/FileRow.svelte.spec.ts index 24f0be8c..5de7adb1 100644 --- a/src/lib/components/storage/explorer/__tests__/FileRow.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/FileRow.svelte.spec.ts @@ -74,7 +74,7 @@ describe('FileRow', () => { const state = createState([file]); render(FileRowWrapper, { state, file }); - await expect.element(page.getByText('unknown-file')).toBeInTheDocument(); + await expect.element(page.getByText('unknown-file').first()).toBeInTheDocument(); }); it('should show checkbox when showCheckboxes is true', async () => { @@ -121,16 +121,65 @@ describe('FileRow', () => { render(FileRowWrapper, { state, file }); await page.getByRole('row').click(); - expect(spy).toHaveBeenCalledWith('click.txt', false); + expect(spy).toHaveBeenCalledWith('click.txt', false, false); }); + async function dblClickRow(): Promise { + const row = page.getByRole('row').element(); + row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + } + it('should call executeAction preview on double click', async () => { const file = makeFile({ key: 'dbl.txt', contentType: 'text/plain' }); const state = createState([file]); const spy = vi.spyOn(state, 'executeAction'); render(FileRowWrapper, { state, file }); - await page.getByRole('row').dblClick(); + await dblClickRow(); + expect(spy).toHaveBeenCalledWith('preview'); + }); + + it('should call enterArchive on double click for zip files', async () => { + const file = makeFile({ key: 'archive.zip', contentType: 'application/zip' }); + const state = createState([file]); + const spy = vi.spyOn(state.archive, 'enterArchive'); + render(FileRowWrapper, { state, file }); + + await dblClickRow(); + expect(spy).toHaveBeenCalledWith('archive.zip'); + }); + + it('should call enterArchive on double click for tar.gz files', async () => { + const file = makeFile({ key: 'bundle.tar.gz', contentType: 'application/gzip' }); + const state = createState([file]); + const spy = vi.spyOn(state.archive, 'enterArchive'); + render(FileRowWrapper, { state, file }); + + await dblClickRow(); + expect(spy).toHaveBeenCalledWith('bundle.tar.gz'); + }); + + it('should call enterArchive on double click for nested archive inside an archive', async () => { + const file = makeFile({ key: 'nested.zip', contentType: undefined }); + const state = createState([file]); + state.archive.archiveKey = 'outer.zip'; + state.archive.archivePrefix = ''; + const spy = vi.spyOn(state.archive, 'enterArchive'); + render(FileRowWrapper, { state, file }); + + await dblClickRow(); + expect(spy).toHaveBeenCalledWith('nested.zip'); + }); + + it('should call executeAction preview on double click for non-archive file inside archive', async () => { + const file = makeFile({ key: 'readme.txt', contentType: 'text/plain' }); + const state = createState([file]); + state.archive.archiveKey = 'outer.zip'; + state.archive.archivePrefix = ''; + const spy = vi.spyOn(state, 'executeAction'); + render(FileRowWrapper, { state, file }); + + await dblClickRow(); expect(spy).toHaveBeenCalledWith('preview'); }); @@ -192,7 +241,7 @@ describe('FileRow', () => { render(FileRowWrapper, { state, file }); await page.getByRole('row').click({ modifiers: ['ControlOrMeta'] }); - expect(spy).toHaveBeenCalledWith('ctrl.txt', true); + expect(spy).toHaveBeenCalledWith('ctrl.txt', true, false); }); it('should call toggleSelect on checkbox change', async () => { diff --git a/src/lib/components/storage/explorer/__tests__/FolderRow.svelte.spec.ts b/src/lib/components/storage/explorer/__tests__/FolderRow.svelte.spec.ts index c0fc22c5..725d45dc 100644 --- a/src/lib/components/storage/explorer/__tests__/FolderRow.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/FolderRow.svelte.spec.ts @@ -70,7 +70,7 @@ describe('FolderRow', () => { render(FolderRowWrapper, { state, folder }); await page.getByRole('row').click(); - expect(spy).toHaveBeenCalledWith('photos/', true); + expect(spy).toHaveBeenCalledWith('photos/', true, false); }); it('should call openContextMenu on right click', async () => { @@ -127,21 +127,21 @@ describe('FolderRow', () => { render(FolderRowWrapper, { state, folder }); await page.getByRole('row').click({ modifiers: ['ControlOrMeta'] }); - expect(spy).toHaveBeenCalledWith('photos/', true); + expect(spy).toHaveBeenCalledWith('photos/', true, false); }); - it('should navigate on double-click with ctrl/meta key', async () => { + it('should navigate on double-click in selection mode', async () => { const folder = makeFolder('photos/'); const state = createState([folder], { selectionMode: true }); const spy = vi.spyOn(state, 'navigate'); render(FolderRowWrapper, { state, folder }); const row = page.getByRole('row').element(); - row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, ctrlKey: true })); + row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); expect(spy).toHaveBeenCalledWith('photos/'); }); - it('should not navigate on double-click without ctrl/meta key', async () => { + it('should navigate on double-click without a modifier', async () => { const folder = makeFolder('photos/'); const state = createState([folder]); const spy = vi.spyOn(state, 'navigate'); @@ -150,8 +150,7 @@ describe('FolderRow', () => { spy.mockClear(); const row = page.getByRole('row').element(); row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); - // ondblclick without modifier does nothing, so navigate should not be called from dblclick - expect(spy).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith('photos/'); }); it('should highlight row when context menu is open for this folder', async () => { diff --git a/src/lib/components/storage/explorer/__tests__/ObjectTable.svelte.spec.ts b/src/lib/components/storage/explorer/__tests__/ObjectTable.svelte.spec.ts index a2c31542..5579dff5 100644 --- a/src/lib/components/storage/explorer/__tests__/ObjectTable.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/ObjectTable.svelte.spec.ts @@ -70,6 +70,14 @@ describe('ObjectTable', () => { await expect.element(page.getByText('This bucket is empty')).toBeInTheDocument(); }); + it('should call an empty folder a folder, not a bucket', async () => { + const state = createState([], { prefix: 'docs/' }); + render(ObjectTableWrapper, { state }); + + await expect.element(page.getByText('This folder is empty')).toBeInTheDocument(); + expect(page.getByText('This bucket is empty').elements().length).toBe(0); + }); + it('should show parent directory row when prefix is set', async () => { const state = createState([], { prefix: 'docs/' }); render(ObjectTableWrapper, { state }); diff --git a/src/lib/components/storage/explorer/__tests__/SelectionToolbar.svelte.spec.ts b/src/lib/components/storage/explorer/__tests__/SelectionToolbar.svelte.spec.ts index ecb33bd2..e350ec06 100644 --- a/src/lib/components/storage/explorer/__tests__/SelectionToolbar.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/SelectionToolbar.svelte.spec.ts @@ -39,6 +39,14 @@ describe('SelectionToolbar', () => { await expect.element(page.getByText('2 selected')).toBeInTheDocument(); }); + it('shows the cumulative size of selected files', async () => { + const files = [makeFile('a.txt'), makeFile('b.txt')]; + const state = createState(files, ['a.txt', 'b.txt']); + render(SelectionToolbarWrapper, { state }); + + await expect.element(page.getByText('Selected files: 2.05 kB')).toBeInTheDocument(); + }); + it('should enable preview button when exactly 1 file selected', async () => { const files = [makeFile('a.txt')]; const state = createState(files, ['a.txt']); @@ -75,13 +83,22 @@ describe('SelectionToolbar', () => { await expect.element(btn).not.toBeDisabled(); }); - it('should disable download when folder selected', async () => { + it('should enable download when folder selected', async () => { const objects = [makeFolder('dir/')]; const state = createState(objects, ['dir/']); render(SelectionToolbarWrapper, { state }); const btn = page.getByRole('button', { name: /Download/ }); - await expect.element(btn).toBeDisabled(); + await expect.element(btn).not.toBeDisabled(); + }); + + it('should enable download when multiple files are selected', async () => { + const files = [makeFile('a.txt'), makeFile('b.txt')]; + const state = createState(files, ['a.txt', 'b.txt']); + render(SelectionToolbarWrapper, { state }); + + const btn = page.getByRole('button', { name: /Download/ }); + await expect.element(btn).not.toBeDisabled(); }); it('should enable delete button when items are selected', async () => { diff --git a/src/lib/components/storage/explorer/__tests__/StorageBreadcrumb.svelte.spec.ts b/src/lib/components/storage/explorer/__tests__/StorageBreadcrumb.svelte.spec.ts index 3bbffaaa..50111caa 100644 --- a/src/lib/components/storage/explorer/__tests__/StorageBreadcrumb.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/StorageBreadcrumb.svelte.spec.ts @@ -1,4 +1,4 @@ -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { faker } from '@faker-js/faker'; @@ -81,8 +81,19 @@ describe('StorageBreadcrumb', () => { const state = createState({ folders: makeFolders(3), files: makeFiles(5) }); render(StorageBreadcrumbWrapper, { state }); - await expect.element(page.getByText('3')).toBeInTheDocument(); - await expect.element(page.getByText('5')).toBeInTheDocument(); + await expect.element(page.getByText('3', { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText('5', { exact: true })).toBeInTheDocument(); + }); + + it('should give the count badges a text alternative', async () => { + const state = createState({ folders: makeFolders(3), files: makeFiles(1) }); + render(StorageBreadcrumbWrapper, { state }); + + await expect.element(page.getByText('3 folders', { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText('1 file', { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByText('3', { exact: true })) + .toHaveAttribute('aria-hidden', 'true'); }); }); @@ -173,6 +184,28 @@ describe('StorageBreadcrumb', () => { }); }); + describe('create menu', () => { + it('should ignore Enter while a create is in flight', async () => { + const state = createState(); + let resolveCreate: (created: boolean) => void = () => {}; + const spy = vi + .spyOn(state, 'confirmCreate') + .mockImplementation(() => new Promise((resolve) => (resolveCreate = resolve))); + render(StorageBreadcrumbWrapper, { state }); + + await page.getByRole('button', { name: /create/i }).click(); + await page.getByRole('menuitem', { name: /text file/i }).click(); + const input = page.getByLabelText('Name'); + await input.click(); + await userEvent.keyboard('{Enter}'); + await userEvent.keyboard('{Enter}'); + expect(spy).toHaveBeenCalledTimes(1); + + resolveCreate(true); + await expect.element(input).not.toBeInTheDocument(); + }); + }); + describe('special characters in paths', () => { it('should handle paths with spaces', async () => { const state = createState({ prefix: 'my folder/sub dir/' }); @@ -191,16 +224,14 @@ describe('StorageBreadcrumb', () => { }); describe('pin/unpin', () => { - it('should show pin option in more options menu', async () => { + it('should show a pin control for the current location', async () => { const state = createState(); render(StorageBreadcrumbWrapper, { state }); - // The more options button exists - const moreBtn = page.getByRole('button', { name: /more options/i }); - await expect.element(moreBtn).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Pin' })).toBeInTheDocument(); }); - it('should show unpin when current location is pinned', async () => { + it('should show unpin control when current location is pinned', async () => { const state = createState({ bucket: 'test-bucket', prefix: 'data/', @@ -208,35 +239,20 @@ describe('StorageBreadcrumb', () => { }); render(StorageBreadcrumbWrapper, { state }); - // The more menu should contain unpin - const menuItems = page.getByRole('menuitem'); - await expect.element(menuItems.first()).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Unpin' })).toBeInTheDocument(); }); - it('should call pin via more options menu when not pinned', async () => { + it('should pin the current location', async () => { const state = createState({ bucket: 'test-bucket', prefix: 'data/' }); const spy = vi.spyOn(state.bookmarks, 'pin'); render(StorageBreadcrumbWrapper, { state }); - // Open the dropdown by focusing/clicking the trigger - const moreBtn = page.getByRole('button', { name: /more options/i }); - await moreBtn.click(); - - // DaisyUI dropdown uses focus to show content; click the menuitem - const menuItems = page.getByRole('menuitem'); - // Force the click by using element() - const el = menuItems.first(); - await el.click(); - - // If DaisyUI dropdown prevents click, try direct dispatch - if (!spy.mock.calls.length) { - const domEl = (await el.element()) as HTMLElement; - domEl.click(); - } + const pinButton = page.getByRole('button', { name: 'Pin' }).last(); + ((await pinButton.element()) as HTMLElement).click(); expect(spy).toHaveBeenCalledWith('test-bucket', 'data/'); }); - it('should call unpin via more options menu when pinned', async () => { + it('should unpin the current location', async () => { const state = createState({ bucket: 'test-bucket', prefix: 'data/', @@ -245,12 +261,8 @@ describe('StorageBreadcrumb', () => { const spy = vi.spyOn(state.bookmarks, 'unpin'); render(StorageBreadcrumbWrapper, { state }); - // Open the dropdown first - const moreBtn = page.getByRole('button', { name: /more options/i }); - await moreBtn.click(); - - const menuItem = page.getByRole('menuitem'); - await menuItem.first().click(); + const unpinButton = page.getByRole('button', { name: 'Unpin' }); + ((await unpinButton.element()) as HTMLElement).click(); expect(spy).toHaveBeenCalledWith('test-bucket', 'data/'); }); }); @@ -350,7 +362,7 @@ describe('StorageBreadcrumb', () => { const bucketEl = nav.getByText('test-bucket'); await bucketEl.click({ button: 'right' }); - const menuItem = page.getByRole('menuitem'); + const menuItem = page.getByRole('menuitem', { name: /pin/i }); await menuItem.first().click(); expect(spy).toHaveBeenCalledWith('test-bucket', ''); }); @@ -367,7 +379,7 @@ describe('StorageBreadcrumb', () => { const bucketEl = nav.getByText('test-bucket'); await bucketEl.click({ button: 'right' }); - const menuItem = page.getByRole('menuitem'); + const menuItem = page.getByRole('menuitem', { name: /unpin/i }); await menuItem.first().click(); expect(spy).toHaveBeenCalledWith('test-bucket', ''); }); @@ -391,9 +403,8 @@ describe('StorageBreadcrumb', () => { const backdrop = menuUl.previousElementSibling as HTMLElement; backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - // Context menu should be closed - menuitem from ctx menu should be gone - // The only remaining menuitem should be from the more options dropdown - await expect.element(page.getByRole('menuitem')).toBeInTheDocument(); // the one in more options + // Context menu should be closed. + await expect.element(page.getByRole('menuitem').first()).not.toBeInTheDocument(); }); it('should open context menu on right-click of bucket with prefix', async () => { @@ -455,4 +466,68 @@ describe('StorageBreadcrumb', () => { await expect.element(moreBtn).not.toBeInTheDocument(); }); }); + + describe('archive mode', () => { + it('should show archive name when inside an archive', async () => { + const state = createState(); + state.archive.archiveKey = 'data.zip'; + render(StorageBreadcrumbWrapper, { state }); + + const nav = page.getByRole('navigation', { name: 'breadcrumb' }); + await expect.element(nav.getByText('data.zip')).toBeInTheDocument(); + }); + + it('should show internal path parts when navigating within archive', async () => { + const state = createState(); + state.archive.archiveKey = 'data.zip'; + state.archive.archivePrefix = 'music/videos/'; + render(StorageBreadcrumbWrapper, { state }); + + const nav = page.getByRole('navigation', { name: 'breadcrumb' }); + await expect + .element(nav.getByRole('button', { name: 'music', exact: true })) + .toBeInTheDocument(); + await expect.element(nav.getByText('videos')).toHaveAttribute('aria-current', 'page'); + }); + + it('should show nested archive entry when browsing nested archive', async () => { + const state = createState(); + state.archive.archiveKey = 'outer.zip'; + state.archive._restoreFullState({ + archiveKey: 'outer.zip', + archivePrefix: 'subdir/', + archiveNestedPath: 'inner.tar', + previousS3Prefix: '', + archiveLoading: false, + archiveTooLarge: false + }); + state.archive.archivePrefix = 'subdir/'; + render(StorageBreadcrumbWrapper, { state }); + + const nav = page.getByRole('navigation', { name: 'breadcrumb' }); + await expect.element(nav.getByText('outer.zip')).toBeInTheDocument(); + await expect.element(nav.getByText('inner.tar')).toBeInTheDocument(); + await expect.element(nav.getByText('subdir')).toBeInTheDocument(); + }); + + it('should call navigateInArchive when clicking breadcrumb folder inside archive', async () => { + const state = createState(); + state.archive.archiveKey = 'data.zip'; + state.archive.archivePrefix = 'music/videos/'; + const spy = vi.spyOn(state.archive, 'navigateInArchive'); + render(StorageBreadcrumbWrapper, { state }); + + const nav = page.getByRole('navigation', { name: 'breadcrumb' }); + await nav.getByRole('button', { name: 'music', exact: true }).click(); + expect(spy).toHaveBeenCalledWith('music/'); + }); + + it('should hide upload button when in archive mode', async () => { + const state = createState(); + state.archive.archiveKey = 'data.zip'; + render(StorageBreadcrumbWrapper, { state }); + + await expect.element(page.getByRole('button', { name: /upload/i })).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/lib/components/storage/explorer/__tests__/StorageBreadcrumbWrapper.svelte b/src/lib/components/storage/explorer/__tests__/StorageBreadcrumbWrapper.svelte index 073599f2..3c73ae8f 100644 --- a/src/lib/components/storage/explorer/__tests__/StorageBreadcrumbWrapper.svelte +++ b/src/lib/components/storage/explorer/__tests__/StorageBreadcrumbWrapper.svelte @@ -1,15 +1,21 @@ diff --git a/src/lib/components/storage/explorer/__tests__/TabBarWrapper.svelte b/src/lib/components/storage/explorer/__tests__/TabBarWrapper.svelte new file mode 100644 index 00000000..ffebff1c --- /dev/null +++ b/src/lib/components/storage/explorer/__tests__/TabBarWrapper.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/storage/explorer/file-icon/FileIconAndName.svelte b/src/lib/components/storage/explorer/file-icon/FileIconAndName.svelte new file mode 100644 index 00000000..c510f0e5 --- /dev/null +++ b/src/lib/components/storage/explorer/file-icon/FileIconAndName.svelte @@ -0,0 +1,38 @@ + + +
+ {@render renderIcon(config)} + {keyToName(file.key)} + {#if badge} + + {badge} + + {/if} +
+ +{#snippet renderIcon(cfg: IconConfig)} +
@@ -134,23 +197,25 @@ {:else}
- + string)( + locationHref(storage.connectionHostname, loc) + )} data-sveltekit-preload-data="off" class="text-base-content/60 hover:text-primary truncate text-xs" > - {locationPath(loc)} @@ -159,14 +224,15 @@ diff --git a/src/lib/components/storage/landing/StorageConnectForm.svelte b/src/lib/components/storage/landing/StorageConnectForm.svelte index 42eb5a3f..686ce750 100644 --- a/src/lib/components/storage/landing/StorageConnectForm.svelte +++ b/src/lib/components/storage/landing/StorageConnectForm.svelte @@ -1,16 +1,11 @@ -{#if autoConnecting} -
- -

{m.storage_connect_reconnecting()}

-
-{:else} -
- - -
-

{m.storage_connect_title()}

-

{m.storage_connect_subtitle()}

- -
-
- - -
- -
- - - {#if $errors?.host} -

{$errors.host}

- {/if} -
+
+ + +
+

{m.storage_connect_title()}

+

{m.storage_connect_subtitle()}

+ + {#if connectError} + + {/if} + + +
+ + +
+ +
+ + + {#if $errors?.host} +

{$errors.host}

+ {/if} +
+ +
+ + +

{m.storage_connect_port_hint()}

+ {#if $errors?.port} +

{$errors.port}

+ {/if} +
+
- - -

{m.storage_connect_port_hint()}

- {#if $errors?.port} -

{$errors.port}

- {/if} + +

{m.storage_connect_tls_hint()}

- -
+ { + $form.tls = e.currentTarget.checked ? { verification: 'Full' } : undefined; + }} + /> +
+ + {#if $form.tls} +
- -

{m.storage_connect_tls_hint()}

+ +

+ {m.storage_connect_tls_verification_hint()} +

{ - $form.tls = e.currentTarget.checked ? { verification: 'Full' } : undefined; + $form.tls = { verification: e.currentTarget.checked ? 'Full' : 'None' }; }} />
- - {#if $form.tls} -
-
- -

- {m.storage_connect_tls_verification_hint()} -

-
- { - $form.tls = { verification: e.currentTarget.checked ? 'Full' : 'None' }; - }} - /> -
+ {/if} + +
+ + +
+ +
+ + + {#if $errors?.region?.name} +

{$errors.region.name}

{/if} +
+ +
+ + + {#if $errors?.credentials?.accessKey} +

{$errors.credentials.accessKey}

+ {/if} +
+ +
+ + + {#if $errors?.credentials?.secretKey} +

{$errors.credentials.secretKey}

+ {/if} +
-
- - -
- -
- - - {#if $errors?.region?.name} -

{$errors.region.name}

- {/if} -
- -
- - - {#if $errors?.credentials?.accessKey} -

{$errors.credentials.accessKey}

- {/if} -
- -
- - - {#if $errors?.credentials?.secretKey} -

{$errors.credentials.secretKey}

- {/if} -
+ {#if $message?.type === 'error'} +

{$message.message}

+ {/if} - {#if $message?.type === 'error'} -

{$message.message}

+ - -
+ {m.storage_connect_submit()} + +
-{/if} +
diff --git a/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts b/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts index b4008a0a..95909388 100644 --- a/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts +++ b/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts @@ -3,42 +3,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { render } from 'vitest-browser-svelte'; import type { ComponentProps } from 'svelte'; import StorageConnectForm from './StorageConnectForm.svelte'; -import type { SavedConnection } from '$lib/storage/connection-storage.js'; +import type { ConnectionMetadata } from '$lib/storage/connection-types.js'; type ConnectionFormProp = ComponentProps['connectionForm']; -/** Build a minimal StoredConnection fixture with a stable UUID. */ -function makeConn( - overrides: Partial> & { id?: string } = {} -): SavedConnection { - return { - id: overrides.id ?? '00000000-0000-0000-0000-000000000001', - type: 's3', - host: 'minio.example.com', - port: undefined, - tls: { verification: 'Full' }, - accessStyle: 'Path', - region: { name: 'us-east-1' }, - credentials: { accessKey: '', secretKey: '' }, - ...overrides - } as SavedConnection; -} +// Note: $app/navigation is not mocked; invalidateAll is a no-op in test environments. // Mock feature flags to disable auto-connect -vi.mock('$lib/client/feature-flags.js', () => ({ - storageAutoConnectEnabled: false -})); - -// Mock connection-storage module -vi.mock('$lib/storage/connection-storage.js', () => ({ - saveConnectionLocally: vi.fn(), - loadConnectionLocally: vi.fn(() => null), - loadAllConnectionsLocally: vi.fn(() => []), - removeConnectionLocally: vi.fn(), - updateConnectionLocally: vi.fn(), - removeConnectionById: vi.fn(), - loadConnectionById: vi.fn(() => null) -})); +vi.mock('$lib/client/feature-flags.js', () => ({})); // Mock paraglide messages vi.mock('$lib/paraglide/messages.js', () => ({ @@ -66,11 +38,16 @@ vi.mock('$lib/paraglide/messages.js', () => ({ storage_connect_access_key: () => 'Access key', storage_connect_secret_key: () => 'Secret key', storage_connect_submit: () => 'Connect', - storage_connect_forget_label: ({ endpoint }: { endpoint: string }) => `Forget ${endpoint}`, - storage_connect_forget_confirm: ({ endpoint }: { endpoint: string }) => - `Forget connection to ${endpoint}?`, + storage_connect_testing: () => 'Testing connection...', + storage_connect_additional_buckets: () => 'Additional buckets', + storage_connect_additional_buckets_hint: () => 'One per line', storage_connect_forget_cancel: () => 'Cancel', storage_connect_forget: () => 'Forget', + storage_connect_forget_label: ({ endpoint }: { endpoint: string }) => + `Forget connection to ${endpoint}`, + storage_connect_forget_confirm: ({ endpoint }: { endpoint: string }) => + `Remove ${endpoint} from saved connections?`, + storage_connect_error_unreachable: () => 'Could not connect', storage_connect_manage: () => 'Manage connections', storage_connect_edit: () => 'Edit', storage_more_options: () => 'More options', @@ -111,20 +88,83 @@ function createMockForm(overrides: Record = {}): ConnectionForm } as unknown as ConnectionFormProp; } +function makeConnection(overrides: Partial = {}): ConnectionMetadata { + return { + id: 'conn-1', + name: 'My Connection', + endpoint: 'https://example.com', + ...overrides + }; +} + +function renderForm( + formOverrides: Record = {}, + connections: ConnectionMetadata[] = [], + connectError: string | null = null +) { + return render(StorageConnectForm, { + connectionForm: createMockForm(formOverrides), + connections, + connectError + }); +} + describe('StorageConnectForm', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should render the form title and subtitle', async () => { - render(StorageConnectForm, { connectionForm: createMockForm() }); + renderForm(); await expect.element(page.getByText('Connect to Storage')).toBeInTheDocument(); await expect.element(page.getByText('Enter your connection details')).toBeInTheDocument(); }); - it('should render all form fields with labels', async () => { - render(StorageConnectForm, { connectionForm: createMockForm() }); + it('should show "No saved connections" when the store is empty', async () => { + renderForm(); + + await expect.element(page.getByText('No saved connections yet')).toBeInTheDocument(); + }); + + it('should show saved connections from the connection store', async () => { + renderForm({}, [ + makeConnection({ id: 'conn-1', name: 'My MinIO', endpoint: 'https://minio.example.com' }) + ]); + + await expect.element(page.getByText('My MinIO').first()).toBeInTheDocument(); + }); + + it('should show delete buttons for saved connections', async () => { + renderForm({}, [ + makeConnection({ id: 'conn-1', name: 'Test S3', endpoint: 'https://s3.test.io' }) + ]); + + await expect + .element(page.getByRole('button', { name: 'Forget connection to Test S3' })) + .toBeInTheDocument(); + }); + + it('should render multiple saved connections', async () => { + renderForm({}, [ + makeConnection({ id: 'conn-1', name: 'First', endpoint: 'https://first.example.com' }), + makeConnection({ id: 'conn-2', name: 'Second', endpoint: 'https://second.example.com' }) + ]); + + // Use .first() to avoid strict-mode violation: the name appears in both the + // label and the containing + +
+ +
+ diff --git a/src/lib/components/storage/modals/AddBucketModal.svelte.spec.ts b/src/lib/components/storage/modals/AddBucketModal.svelte.spec.ts new file mode 100644 index 00000000..f8aa255f --- /dev/null +++ b/src/lib/components/storage/modals/AddBucketModal.svelte.spec.ts @@ -0,0 +1,251 @@ +import { page, userEvent } from 'vitest/browser'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import AddBucketModal from './AddBucketModal.svelte'; + +// Mock navigation +const mockGoto = vi.fn(); +vi.mock('$app/navigation', () => ({ + goto: (...args: unknown[]) => mockGoto(...args) +})); + +vi.mock('$app/paths', () => ({ + resolve: (_pattern: string, params: Record) => `/storage/${params.bucket}` +})); + +// Mock storage context +const mockAddBucket = vi.fn(); +const mockCheckBucket = vi.fn(); +const mockUpdateConnections = vi.fn(); +vi.mock('$lib/storage/context.js', () => ({ + getStorageState: () => ({ + addBucket: mockAddBucket, + api: { + checkBucket: mockCheckBucket, + updateConnections: mockUpdateConnections + } + }) +})); + +// Mock connection store +vi.mock('$lib/storage/connection-store.svelte.js', () => ({ + connectionStore: { activeConnectionId: 'mock-connection-id', connections: [] } +})); + +// Mock storage errors +vi.mock('$lib/storage/errors.js', () => ({ + StorageError: class StorageError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + this.name = 'StorageError'; + } + } +})); + +// Mock paraglide messages +vi.mock('$lib/paraglide/messages.js', () => ({ + storage_add_bucket_title: () => 'Connect to a bucket', + storage_add_bucket_subtitle: () => 'Enter the name of a bucket you have access to.', + storage_add_bucket_name_label: () => 'Bucket name', + storage_add_bucket_name_placeholder: () => 'my-bucket', + storage_add_bucket_submit: () => 'Connect', + storage_add_bucket_cancel: () => 'Cancel', + storage_add_bucket_error_access_denied: () => + 'Access denied — you do not have permission to read this bucket.', + storage_add_bucket_error_not_found: () => 'Bucket not found — check the name and try again.', + storage_add_bucket_error_unknown: () => + 'Could not connect to this bucket — check the name and try again.' +})); + +describe('AddBucketModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', vi.fn()); + }); + + describe('rendering', () => { + it('should render the dialog when open', async () => { + render(AddBucketModal, { open: true }); + + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await expect + .element(page.getByRole('heading', { name: 'Connect to a bucket' })) + .toBeInTheDocument(); + await expect + .element(page.getByText('Enter the name of a bucket you have access to.')) + .toBeInTheDocument(); + }); + + it('should not render the dialog when closed', async () => { + render(AddBucketModal, { open: false }); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should render the bucket name input', async () => { + render(AddBucketModal, { open: true }); + + await expect.element(page.getByLabelText('Bucket name')).toBeInTheDocument(); + await expect.element(page.getByPlaceholder('my-bucket')).toBeInTheDocument(); + }); + + it('should render Cancel and Connect buttons', async () => { + render(AddBucketModal, { open: true }); + + await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + }); + + it('should have Connect button disabled when input is empty', async () => { + render(AddBucketModal, { open: true }); + + await expect.element(page.getByRole('button', { name: 'Connect' })).toBeDisabled(); + }); + + it('should enable Connect button when input has a value', async () => { + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'my-bucket'); + + await expect.element(page.getByRole('button', { name: 'Connect' })).not.toBeDisabled(); + }); + }); + + describe('successful connection', () => { + it('should call addBucket and navigate on ok response', async () => { + mockCheckBucket.mockResolvedValue({ ok: true, status: 200 }); + mockUpdateConnections.mockResolvedValue(undefined); + + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'my-bucket'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + + await expect.poll(() => mockAddBucket).toHaveBeenCalledWith('my-bucket'); + await expect.poll(() => mockGoto).toHaveBeenCalledWith('/storage/my-bucket'); + }); + + it('should pass the bucket name to checkBucket', async () => { + mockCheckBucket.mockResolvedValue({ ok: true, status: 200 }); + mockUpdateConnections.mockResolvedValue(undefined); + + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'my bucket'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + + await expect.poll(() => mockCheckBucket).toHaveBeenCalledWith({ bucket: 'my bucket' }); + }); + }); + + describe('error handling', () => { + it('should show access denied error on 403', async () => { + mockCheckBucket.mockResolvedValue({ ok: false, status: 403 }); + + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'locked-bucket'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent('Access denied — you do not have permission to read this bucket.'); + }); + + it('should show not found error on 404', async () => { + mockCheckBucket.mockResolvedValue({ ok: false, status: 404 }); + + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'missing-bucket'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent('Bucket not found — check the name and try again.'); + }); + + it('should show generic error on 502', async () => { + mockCheckBucket.mockResolvedValue({ ok: false, status: 502 }); + + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'bad-bucket'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent('Could not connect to this bucket — check the name and try again.'); + }); + + it('should show generic error on network failure', async () => { + mockCheckBucket.mockRejectedValue(new Error('Network error')); + + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'unreachable-bucket'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent('Could not connect to this bucket — check the name and try again.'); + }); + + it('should not navigate or add bucket on error', async () => { + mockCheckBucket.mockResolvedValue({ ok: false, status: 403 }); + + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'locked-bucket'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + + await expect.poll(() => page.getByRole('alert')).toBeInTheDocument(); + expect(mockAddBucket).not.toHaveBeenCalled(); + expect(mockGoto).not.toHaveBeenCalled(); + }); + + it('should clear error when user types a new bucket name', async () => { + mockCheckBucket + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + mockUpdateConnections.mockResolvedValue(undefined); + + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'missing'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + + await userEvent.clear(page.getByLabelText('Bucket name')); + await userEvent.type(page.getByLabelText('Bucket name'), 'good-bucket'); + await userEvent.click(page.getByRole('button', { name: 'Connect' })); + + await expect.element(page.getByRole('alert')).not.toBeInTheDocument(); + }); + }); + + describe('cancel', () => { + it('should close the dialog when Cancel is clicked', async () => { + render(AddBucketModal, { open: true }); + + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + + await userEvent.click(page.getByRole('button', { name: 'Cancel' })); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should reset the input when Cancel is clicked', async () => { + render(AddBucketModal, { open: true }); + + await userEvent.type(page.getByLabelText('Bucket name'), 'some-value'); + await userEvent.click(page.getByRole('button', { name: 'Cancel' })); + + // Re-open + render(AddBucketModal, { open: true }); + await expect.element(page.getByLabelText('Bucket name')).toHaveValue(''); + }); + }); +}); diff --git a/src/lib/components/storage/modals/CreateModal.svelte b/src/lib/components/storage/modals/CreateModal.svelte new file mode 100644 index 00000000..b088c56f --- /dev/null +++ b/src/lib/components/storage/modals/CreateModal.svelte @@ -0,0 +1,119 @@ + + + + + diff --git a/src/lib/components/storage/modals/CreateModal.svelte.spec.ts b/src/lib/components/storage/modals/CreateModal.svelte.spec.ts new file mode 100644 index 00000000..0c479ba5 --- /dev/null +++ b/src/lib/components/storage/modals/CreateModal.svelte.spec.ts @@ -0,0 +1,74 @@ +import { page, userEvent } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import CreateModal from './CreateModal.svelte'; + +function renderModal(props: Partial>[1]> = {}) { + const onConfirm = vi.fn(); + render(CreateModal, { open: true, type: 'file', onConfirm, onCancel: vi.fn(), ...props }); + const message = document.querySelector('.modal-box [aria-live="polite"]')!; + return { onConfirm, input: page.getByLabelText('Name'), message }; +} + +describe('CreateModal', () => { + it('explains an invalid name at the input and does not submit it', async () => { + const { onConfirm, input, message } = renderModal(); + + await input.fill('x?y.txt'); + + await expect.element(message).toHaveTextContent(/without the characters/); + await expect.element(input).toHaveAttribute('aria-invalid', 'true'); + expect(message.id).not.toBe(''); + await expect.element(input).toHaveAttribute('aria-describedby', message.id); + await expect.element(page.getByRole('button', { name: 'Create' })).toBeDisabled(); + + await userEvent.keyboard('{Enter}'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('explains relative path segments and empty names', async () => { + const { input, message } = renderModal({ type: 'folder' }); + + await input.fill('a/../b'); + await expect.element(message).toHaveTextContent(/"\.\." as a path part/); + + await input.fill(' '); + await expect.element(message).toHaveTextContent('Enter a name.'); + }); + + it('explains an empty path segment and does not submit it', async () => { + const { onConfirm, input, message } = renderModal({ type: 'folder' }); + + for (const name of ['/a', 'a//b', 'a/']) { + await input.fill(name); + await expect.element(message).toHaveTextContent(/extra "\/"/); + await expect.element(input).toHaveAttribute('aria-invalid', 'true'); + } + await expect.element(page.getByRole('button', { name: 'Create' })).toBeDisabled(); + await userEvent.keyboard('{Enter}'); + expect(onConfirm).not.toHaveBeenCalled(); + + await input.fill('a/b/c'); + await expect.poll(() => message.textContent?.trim()).toBe(''); + }); + + it('explains a name that makes the key too long', async () => { + const { input, message } = renderModal({ prefix: 'a'.repeat(1000) + '/' }); + + await input.fill('b'.repeat(30)); + + await expect.element(message).toHaveTextContent(/too long/); + }); + + it('accepts a valid name inside a folder with characters S3 allows', async () => { + const { onConfirm, input, message } = renderModal({ prefix: 'reports?2024/' }); + + await input.fill(' summary.txt '); + + await expect.poll(() => message.textContent?.trim()).toBe(''); + await expect.element(input).not.toHaveAttribute('aria-invalid'); + await expect.element(input).not.toHaveAttribute('aria-describedby'); + await page.getByRole('button', { name: 'Create' }).click(); + expect(onConfirm).toHaveBeenCalledWith('summary.txt'); + }); +}); diff --git a/src/lib/components/storage/modals/DeleteConfirmModal.svelte b/src/lib/components/storage/modals/DeleteConfirmModal.svelte index 8e9a98ca..8b18dae0 100644 --- a/src/lib/components/storage/modals/DeleteConfirmModal.svelte +++ b/src/lib/components/storage/modals/DeleteConfirmModal.svelte @@ -44,10 +44,10 @@ {/if} diff --git a/src/lib/components/storage/modals/DetailsModal.svelte b/src/lib/components/storage/modals/DetailsModal.svelte new file mode 100644 index 00000000..3c0385fb --- /dev/null +++ b/src/lib/components/storage/modals/DetailsModal.svelte @@ -0,0 +1,155 @@ + + + + + diff --git a/src/lib/components/storage/modals/MoveConfirmModal.svelte b/src/lib/components/storage/modals/MoveConfirmModal.svelte new file mode 100644 index 00000000..abd3580e --- /dev/null +++ b/src/lib/components/storage/modals/MoveConfirmModal.svelte @@ -0,0 +1,85 @@ + + + + + diff --git a/src/lib/components/storage/modals/PreviewModal.save.svelte.spec.ts b/src/lib/components/storage/modals/PreviewModal.save.svelte.spec.ts new file mode 100644 index 00000000..836fab79 --- /dev/null +++ b/src/lib/components/storage/modals/PreviewModal.save.svelte.spec.ts @@ -0,0 +1,98 @@ +import { page, userEvent } from 'vitest/browser'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from 'vitest-browser-svelte'; +import PreviewModal from './PreviewModal.svelte'; + +// Replace Monaco with an editable textarea so saving can be exercised. +vi.mock('$lib/components/editor/TextEditor.svelte', async () => ({ + default: (await import('../../../../test/TextEditorStub.svelte')).default +})); + +vi.mock('$app/paths', () => ({ resolve: (path: string) => path })); + +const { previewMock, saveTextMock } = vi.hoisted(() => ({ + previewMock: vi.fn(), + saveTextMock: vi.fn() +})); + +vi.mock('$lib/storage/context.js', () => ({ + getStorageState: () => ({ + api: { preview: previewMock, archiveExtract: vi.fn(), saveText: saveTextMock }, + bucket: 'test-bucket' + }) +})); + +const props = { open: true, bucket: 'b', objectKey: 'notes.txt' }; + +function previewResponse(bytes: Uint8Array, etag: string): Response { + return new Response(new Blob([bytes]), { + status: 200, + headers: { + 'Content-Type': 'text/plain', + 'X-Preview-Format': 'text', + 'X-Preview-Truncated': 'false', + 'X-Preview-Total-Size': String(bytes.length), + 'X-Preview-Bytes': String(bytes.length), + 'X-Preview-ETag': etag + } + }); +} + +const editor = () => page.getByRole('textbox', { name: 'Text editor' }); +const saveButton = () => page.getByRole('button', { name: 'Save', exact: true }); + +describe('PreviewModal saving', () => { + beforeEach(() => { + const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode('hello')]); + previewMock.mockResolvedValue(previewResponse(withBom, '"e1"')); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('sends the loaded ETag, restores the BOM and uses the new ETag next time', async () => { + saveTextMock.mockResolvedValueOnce({ etag: '"e2"' }).mockResolvedValueOnce({ etag: '"e3"' }); + render(PreviewModal, props); + + await expect.element(editor()).toHaveValue('hello'); + await editor().fill('hello!'); + await saveButton().click(); + + await vi.waitFor(() => expect(saveTextMock).toHaveBeenCalledTimes(1)); + expect(saveTextMock).toHaveBeenLastCalledWith({ + bucket: 'b', + key: 'notes.txt', + body: 'hello!', + etag: '"e1"', + contentType: 'text/plain' + }); + await expect.element(saveButton()).toBeDisabled(); + + await editor().fill('hello!!'); + await saveButton().click(); + + await vi.waitFor(() => expect(saveTextMock).toHaveBeenCalledTimes(2)); + expect(saveTextMock).toHaveBeenLastCalledWith( + expect.objectContaining({ body: 'hello!!', etag: '"e2"' }) + ); + }); + + it('ignores a second save while one is in flight', async () => { + let finish!: (value: { etag: string }) => void; + saveTextMock.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + render(PreviewModal, props); + + await expect.element(editor()).toHaveValue('hello'); + await editor().fill('hello!'); + await editor().click(); + await userEvent.keyboard('{Control>}s{/Control}'); + await userEvent.keyboard('{Control>}s{/Control}'); + + await vi.waitFor(() => expect(saveTextMock).toHaveBeenCalledTimes(1)); + finish({ etag: '"e2"' }); + await expect.element(saveButton()).toBeDisabled(); + expect(saveTextMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/components/storage/modals/PreviewModal.svelte b/src/lib/components/storage/modals/PreviewModal.svelte index adcfb244..d520d29f 100644 --- a/src/lib/components/storage/modals/PreviewModal.svelte +++ b/src/lib/components/storage/modals/PreviewModal.svelte @@ -5,26 +5,62 @@ import IconClose from 'virtual:icons/material-symbols/close'; import IconErrorOutline from 'virtual:icons/material-symbols/error-outline'; import IconDownload from 'virtual:icons/material-symbols/download'; + import IconFilePresent from 'virtual:icons/material-symbols/file-present'; + import IconSave from 'virtual:icons/material-symbols/save'; + import IconBlock from 'virtual:icons/material-symbols/block'; import * as m from '$lib/paraglide/messages.js'; + import { getLocale } from '$lib/paraglide/runtime.js'; import Modal from '$lib/components/Modal.svelte'; - import TextPreview from './preview/TextPreview.svelte'; + import TooltipTrigger from '$lib/components/TooltipTrigger.svelte'; + import TextEditor from '$lib/components/editor/TextEditor.svelte'; + import UnsavedConfirmDialog from './UnsavedConfirmDialog.svelte'; import CsvPreview from './preview/CsvPreview.svelte'; + import ParquetPreview from './preview/ParquetPreview.svelte'; + import ParquetMetadata from './preview/ParquetMetadata.svelte'; import ImagePreview from './preview/ImagePreview.svelte'; import PdfPreview from './preview/PdfPreview.svelte'; import FallbackPreview from './preview/FallbackPreview.svelte'; import { keyToName, formatFileSize } from '$lib/storage/utils.js'; - import { downloadObject, DownloadError } from '$lib/storage/download.js'; - import { loadConnectionLocally, getConnectionHeader } from '$lib/storage/connection-storage.js'; + import { StorageError } from '$lib/storage/errors.js'; + import { decodePreviewText } from '$lib/storage/text-decode.js'; import { addToast } from '$lib/stores/toast.svelte.js'; - import { STORAGE_CONNECTION_HEADER } from '$lib/storage/connection-storage.js'; + import { maxEditableFileSize, infiniteScrollEnabled } from '$lib/client/feature-flags.js'; + import { getStorageState } from '$lib/storage/context.js'; interface Props { - open: boolean; - bucket: string; - objectKey: string | null; + open?: boolean; + bucket?: string; + objectKey?: string | null; + archiveKey?: string; + archivePath?: string; + nestedArchivePath?: string; + } + interface ColumnStats { + nullCount: number | null; + distinctCount: number | null; + min: string | null; + max: string | null; + } + + interface ColumnTypeInfo { + name: string; + type: string; + codec: string; + compressedSize: number; + uncompressedSize: number; + stats: ColumnStats; } - let { open = $bindable(false), bucket, objectKey }: Props = $props(); + interface ParquetFileMeta { + rowGroups: number; + compressionCodecs: string[]; + compressionUniform: boolean; + hasOffsetIndex: boolean; + hasColumnIndex: boolean; + createdBy: string | null; + version: number; + arrowSchema: string | null; + } type PreviewKind = | { kind: 'idle' } @@ -36,6 +72,11 @@ truncated: boolean; totalSize: number; previewBytes: number; + /** ETag the content was loaded with; saves are conditional on it. */ + etag: string | null; + /** Re-encoding the text as UTF-8 reproduces the stored bytes. */ + lossless: boolean; + utf8Bom: boolean; } | { kind: 'csv'; @@ -46,28 +87,89 @@ previewRows: number; previewColumns: number; } + | { + kind: 'csv_scroll'; + headers: string[]; + rows: unknown[][]; + truncated: boolean; + totalSize: number; + totalRows: number; + } | { kind: 'parquet'; - text: string; + headers: string[]; + columnTypes: ColumnTypeInfo[]; + metadata: ParquetFileMeta; + rows: unknown[][]; + dataBlocked: boolean; truncated: boolean; totalSize: number; totalRows: number; - previewRows: number; } | { kind: 'image'; blobUrl: string; contentType: string; totalSize: number } | { kind: 'pdf'; blobUrl: string; totalSize: number } - | { kind: 'fallback'; contentType: string; isBinary: boolean; imageTooLarge?: boolean } + | { + kind: 'fallback'; + contentType: string; + isBinary: boolean; + imageTooLarge?: boolean; + isSevenZip?: boolean; + isRar?: boolean; + } | { kind: 'error'; message: string }; - let preview: PreviewKind = $state({ kind: 'idle' }); - // Not $state — the template never reads blobUrls directly, only preview.blobUrl. - // Keeping it non-reactive prevents a read/write cycle inside the $effect below. - let blobUrls: string[] = []; + let { + open = $bindable(false), + bucket = '', + objectKey = null, + archiveKey = '', + archivePath = '', + nestedArchivePath = '' + }: Props = $props(); + + const storage = getStorageState(); + let preview: PreviewKind = $state({ kind: 'idle' }), + blobUrls: string[] = []; let maximized = $state(false); - let imageNaturalWidth = $state(0); - let imageNaturalHeight = $state(0); + let imageNaturalWidth = $state(0), + imageNaturalHeight = $state(0); + let parquetShowingRowsCount = $state(0), + csvShowingRowsCount = $state(0); + let csvTotalRows = $state(0); + let parquetTab: 'metadata' | 'data' = $state('metadata'), + parquetDataLoading = $state(false); + let editorText = $state(''); + let originalText = $state(''); + let saving = $state(false); + let showUnsavedConfirm = $state(false); + let editorReady = $state(false); + + const dirty = $derived(editorText !== originalText); + + $effect(() => { + if (!dirty) return; - // Revoke blob URLs when the component is destroyed + function handleBeforeUnload(e: BeforeUnloadEvent) { + e.preventDefault(); + } + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }); + + const filename = $derived(objectKey ? keyToName(objectKey) : ''); + /** Why the loaded text must not be saved back, or `null` when it may be. */ + const readOnlyMessage = $derived.by(() => { + if (preview.kind !== 'text') return null; + if (preview.totalSize > maxEditableFileSize) { + return m.storage_editor_too_large_badge({ limit: formatFileSize(maxEditableFileSize) }); + } + if (preview.truncated) return m.storage_editor_readonly_truncated(); + if (!preview.lossless) return m.storage_editor_readonly_encoding(); + if (archiveKey) return m.storage_editor_readonly_archive(); + if (!preview.etag) return m.storage_editor_readonly_no_etag(); + return null; + }); onDestroy(() => { for (const url of blobUrls) { URL.revokeObjectURL(url); @@ -81,31 +183,44 @@ blobUrls = []; } - // Load preview whenever the modal opens or the key changes $effect(() => { if (open && objectKey) { + editorReady = false; void loadPreview(objectKey, bucket); } + if (!open) { revokeBlobUrls(); preview = { kind: 'idle' }; imageNaturalWidth = 0; imageNaturalHeight = 0; + parquetTab = 'metadata'; + parquetDataLoading = false; + parquetShowingRowsCount = 0; + csvShowingRowsCount = 0; + csvTotalRows = 0; + editorReady = false; + editorText = ''; + originalText = ''; } }); - async function loadPreview(key: string, bkt: string) { + async function loadPreview(key: string, activeBucket: string) { preview = { kind: 'loading' }; revokeBlobUrls(); - const conn = loadConnectionLocally(); - const headers: HeadersInit = conn - ? { [STORAGE_CONNECTION_HEADER]: getConnectionHeader(conn) } - : {}; - try { - const params = new URLSearchParams({ bucket: bkt, key }); - const res = await fetch(`/api/storage/preview?${params}`, { headers }); + let res: Response; + if (archiveKey && archivePath) { + res = await storage.api.archiveExtract({ + bucket: activeBucket, + key: archiveKey, + path: archivePath, + nestedArchivePath: nestedArchivePath || undefined + }); + } else { + res = await storage.api.preview({ bucket: activeBucket, key }); + } if (!res.ok) { const errBody = await res.json().catch(() => ({})); @@ -122,22 +237,33 @@ const contentType = (res.headers.get('Content-Type') ?? 'application/octet-stream') .split(';')[0] .trim(); - const format = res.headers.get('X-Preview-Format'); + const totalSize = parseInt( + res.headers.get('X-Preview-Total-Size') ?? res.headers.get('Content-Length') ?? '0', + 10 + ); + const previewBytes = parseInt( + res.headers.get('X-Preview-Bytes') ?? res.headers.get('Content-Length') ?? '0', + 10 + ); const truncated = res.headers.get('X-Preview-Truncated') === 'true'; - const totalSize = Number(res.headers.get('X-Preview-Total-Size') ?? '0'); - const previewBytes = Number(res.headers.get('X-Preview-Bytes') ?? '0'); - const totalRows = Number(res.headers.get('X-Preview-Total-Rows') ?? '0'); const previewRows = Number(res.headers.get('X-Preview-Preview-Rows') ?? '0'); const previewColumns = Number(res.headers.get('X-Preview-Preview-Columns') ?? '0'); - // Server flagged this as a known-binary type — skip body fetch entirely. - if (res.headers.get('X-Preview-Renderable') === 'false') { - await res.body?.cancel(); - preview = { kind: 'fallback', contentType, isBinary: false }; + if (key.toLowerCase().endsWith('.7z') || contentType === 'application/x-7z-compressed') { + preview = { kind: 'fallback', contentType, isBinary: true, isSevenZip: true }; + return; + } + + if ( + key.toLowerCase().endsWith('.rar') || + ['application/vnd.rar', 'application/x-rar-compressed', 'application/x-rar'].includes( + contentType + ) + ) { + preview = { kind: 'fallback', contentType, isBinary: true, isRar: true }; return; } - // Images — render as blob URL, or fallback if truncated if (contentType.startsWith('image/')) { if (truncated) { await res.body?.cancel(); @@ -151,7 +277,6 @@ return; } - // PDF — render in iframe if (contentType === 'application/pdf') { const blob = await res.blob(); const url = URL.createObjectURL(blob); @@ -160,27 +285,107 @@ return; } - // For everything else (text/*, application/json, application/octet-stream, - // application/yaml, etc.) attempt UTF-8 decode. Success → text view; - // failure → the file is genuinely binary. - const text = await readTextSafely(res, key, contentType); - if (text === null) { - preview = { kind: 'fallback', contentType, isBinary: true }; + if (res.headers.get('X-Preview-Format') === 'parquet') { + const dataBlocked = res.headers.get('X-Preview-Data-Blocked') === 'true'; + let parquetHeaders: string[] = []; + let parquetColumnTypes: ColumnTypeInfo[] = []; + let parquetMeta: ParquetFileMeta = { + rowGroups: 0, + compressionCodecs: [], + compressionUniform: true, + hasOffsetIndex: false, + hasColumnIndex: false, + createdBy: null, + version: 0, + arrowSchema: null + }; + const parquetRows: unknown[][] = []; + const columnPos: Record = {}; + let parquetTotalRows = 0; + await parseParquetStream( + res, + (headers, totalRows, columnTypes, meta) => { + parquetHeaders = headers; + parquetTotalRows = totalRows; + parquetColumnTypes = columnTypes; + parquetMeta = meta; + }, + (name, values) => { + const colIdx = parquetHeaders.indexOf(name); + if (colIdx < 0) return; + let pos = columnPos[name] ?? 0; + for (let i = 0; i < values.length; i++) { + while (parquetRows.length <= pos) { + parquetRows.push(new Array(parquetHeaders.length).fill(undefined)); + } + parquetRows[pos][colIdx] = values[i]; + pos++; + } + columnPos[name] = pos; + } + ); + preview = { + kind: 'parquet', + headers: parquetHeaders, + columnTypes: parquetColumnTypes, + metadata: parquetMeta, + rows: parquetRows, + dataBlocked, + truncated: parquetRows.length < parquetTotalRows, + totalSize, + totalRows: parquetTotalRows + }; return; } - // Parquet data pre-parsed by the server and returned as CSV text. - if (format === 'parquet') { - preview = { kind: 'parquet', text, truncated, totalSize, totalRows, previewRows }; + if (res.headers.get('X-Preview-Format') === 'csv') { + const { + headers: csvHeaders, + rows: csvRows, + totalRows: initialTotal + } = await readCsvNdjsonStream(res); + csvTotalRows = initialTotal; + + if (csvRows.length > 0 || infiniteScrollEnabled || !objectKey) { + preview = { + kind: 'csv_scroll', + headers: csvHeaders, + rows: csvRows, + totalRows: initialTotal, + truncated: initialTotal > csvRows.length, + totalSize + }; + } else { + const data = await fetchCsvRows(0, 250).catch(() => []); + preview = { + kind: 'csv_scroll', + headers: csvHeaders, + rows: data, + totalRows: csvTotalRows, + truncated: csvTotalRows > data.length, + totalSize + }; + } + return; + } + + const decoded = await res + .arrayBuffer() + .then((buf) => decodePreviewText(new Uint8Array(buf), { key, contentType, truncated })) + .catch(() => null); + if (decoded === null) { + preview = { kind: 'fallback', contentType, isBinary: true }; return; } + const { text } = decoded; - // CSV by content-type or file extension if ( contentType === 'text/csv' || contentType === 'application/csv' || contentType === 'application/vnd.ms-excel' || - key.toLowerCase().endsWith('.csv') + contentType === 'text/tab-separated-values' || + key.toLowerCase().endsWith('.csv') || + key.toLowerCase().endsWith('.tsv') ) { preview = { kind: 'csv', @@ -194,89 +399,186 @@ return; } - // All other decoded text - preview = { kind: 'text', text, contentType, truncated, totalSize, previewBytes }; + preview = { + kind: 'text', + text, + contentType, + truncated, + totalSize, + previewBytes, + etag: res.headers.get('X-Preview-ETag'), + lossless: decoded.lossless, + utf8Bom: decoded.utf8Bom + }; + editorText = text; + originalText = text; } catch { preview = { kind: 'error', message: m.storage_preview_error_desc() }; } } - /** - * Read response body as text. Handles UTF-16 BOMs, strict UTF-8, and falls - * back to Windows-1252 for CSV/TSV files (common for Excel-exported CSVs). - * Returns null if the content cannot be decoded as any recognised encoding - * (indicating genuinely binary content). - */ - function isTextContentType(contentType: string): boolean { - if (contentType.startsWith('text/')) return true; - if (contentType === 'application/json') return true; - if (contentType === 'application/yaml') return true; - if (contentType === 'application/xml') return true; - if (contentType === 'application/csv') return true; - if (contentType === 'application/x-ndjson') return true; - return false; + /** Parse an NDJSON parquet stream with callbacks for headers (incl. schema/metadata) and columns. */ + async function parseParquetStream( + res: Response, + onHeaders: ( + headers: string[], + totalRows: number, + columnTypes: ColumnTypeInfo[], + metadata: ParquetFileMeta + ) => void, + onColumn: (name: string, values: unknown[]) => void + ): Promise { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + + if (msg.t === 'h') { + onHeaders( + msg.h, + msg.tr, + msg.s ?? [], + msg.m ?? { + rowGroups: 0, + compressionCodecs: [], + compressionUniform: true, + hasOffsetIndex: false, + hasColumnIndex: false, + createdBy: null, + version: 0, + arrowSchema: null + } + ); + } else if (msg.t === 'c') { + onColumn(msg.n, msg.v); + } else if (msg.t === 'e') { + throw new Error('Server error reading parquet data'); + } + } + } } - async function readTextSafely( + /** Read an NDJSON streaming response and progressively fill column data. */ + async function readNdjsonStream( res: Response, - key: string, - contentType: string - ): Promise { - try { - const buf = await res.arrayBuffer(); - const bytes = new Uint8Array(buf); - const truncated = res.headers.get('X-Preview-Truncated') === 'true'; + onColumn: ((name: string, values: unknown[]) => void) | undefined + ): Promise<{ headers: string[]; rows: unknown[][]; totalRows: number }> { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let resultHeaders: string[] = []; + let rows: unknown[][] = []; + let resultTotalRows = 0; + const columnPos: Record = {}; - // Detect UTF-16 BOM (common in Excel "Save as CSV (UTF-16)") - if (bytes.length >= 2) { - if (bytes[0] === 0xff && bytes[1] === 0xfe) { - return new TextDecoder('utf-16le').decode(buf); - } - if (bytes[0] === 0xfe && bytes[1] === 0xff) { - return new TextDecoder('utf-16be').decode(buf); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + + if (msg.t === 'h') { + resultHeaders = msg.h; + resultTotalRows = msg.tr; + } else if (msg.t === 'c') { + const colIdx = resultHeaders.indexOf(msg.n); + if (colIdx < 0) continue; + const values = msg.v as unknown[]; + let pos = columnPos[msg.n] ?? 0; + for (let i = 0; i < values.length; i++) { + while (rows.length <= pos) { + rows.push(new Array(resultHeaders.length).fill(undefined)); + } + rows[pos][colIdx] = values[i]; + pos++; + } + columnPos[msg.n] = pos; + onColumn?.(msg.n, values); + } else if (msg.t === 'e') { + throw new Error('Server error reading parquet data'); } } + } - // Try strict UTF-8 (handles UTF-8 with or without BOM) - try { - return new TextDecoder('utf-8', { fatal: true }).decode(buf); - } catch { - // If the content was truncated, the invalid bytes might be at the - // truncation boundary (a multi-byte character cut in half). Decode - // without fatal and strip trailing replacement characters. - // Only do this for text-like content types — binary files should - // fall through to the FallbackPreview. - if (truncated && isTextContentType(contentType)) { - const text = new TextDecoder('utf-8').decode(buf); - return text.replace(/\uFFFD+$/, ''); - } + return { headers: resultHeaders, rows, totalRows: resultTotalRows }; + } - // For CSV/TSV files try Windows-1252 — the default encoding used by - // Excel on Windows when exporting to CSV. - const lowerKey = key.toLowerCase(); - if (lowerKey.endsWith('.csv') || lowerKey.endsWith('.tsv')) { - return new TextDecoder('windows-1252').decode(buf); + /** Read an NDJSON streaming response for CSV row data. */ + async function readCsvNdjsonStream( + res: Response + ): Promise<{ headers: string[]; rows: unknown[][]; totalRows: number }> { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let resultHeaders: string[] = []; + let rows: unknown[][] = []; + let resultTotalRows = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + + if (msg.t === 'h') { + resultHeaders = msg.h; + resultTotalRows = msg.tr ?? 0; + } else if (msg.t === 'r') { + rows = msg.v as unknown[][]; + } else if (msg.t === 'e') { + throw new Error('Server error reading CSV data'); } - return null; } - } catch { - return null; } - } - const filename = $derived(objectKey ? keyToName(objectKey) : ''); + return { headers: resultHeaders, rows, totalRows: resultTotalRows }; + } async function triggerDownload() { if (!objectKey) return; - const conn = loadConnectionLocally(); - if (!conn) { - addToast('error', m.storage_download_error_unknown()); - return; - } + try { - await downloadObject(bucket, objectKey, getConnectionHeader(conn)); + const res = await storage.api.download({ bucket, key: objectKey }); + if (!res.ok) { + addToast('error', m.storage_download_error_unknown()); + return; + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const filename = objectKey.split('/').filter(Boolean).pop() ?? objectKey; + const anchor = document.createElement('a'); + anchor.href = blobUrl; + anchor.download = filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + setTimeout(() => URL.revokeObjectURL(blobUrl), 10_000); } catch (err) { - if (err instanceof DownloadError) { + if (err instanceof StorageError) { addToast('error', err.message); } else { addToast('error', m.storage_download_error_unknown()); @@ -284,22 +586,203 @@ } } + async function handleSave() { + // Ctrl+S can fire while a save is in flight; a second request would + // still carry the old ETag and be rejected as a conflict. + if (saving || !objectKey || preview.kind !== 'text') return; + if (readOnlyMessage || !preview.etag) { + addToast('error', readOnlyMessage ?? m.storage_editor_error()); + return; + } + const loaded = preview; + const savedText = editorText; + + saving = true; + try { + const { etag } = await storage.api.saveText({ + bucket, + key: objectKey, + body: (loaded.utf8Bom ? '\uFEFF' : '') + savedText, + etag: loaded.etag!, + contentType: loaded.contentType + }); + + loaded.etag = etag; + originalText = savedText; + addToast('success', m.storage_editor_saved()); + } catch (err) { + if (err instanceof StorageError) { + if (err.code === 'conflict') { + addToast('error', m.storage_editor_conflict()); + } else if (err.code === 'not_connected') { + addToast('error', m.storage_upload_error_not_connected()); + } else if (err.code === 'access_denied') { + addToast('error', m.storage_upload_error_access_denied()); + } else if (err.code === 'file_too_large') { + addToast( + 'error', + m.storage_editor_too_large({ limit: formatFileSize(maxEditableFileSize) }) + ); + } else { + addToast('error', err.message || m.storage_editor_error()); + } + } else { + addToast('error', m.storage_editor_error()); + } + } finally { + saving = false; + } + } + + async function handleSaveAndClose() { + await handleSave(); + if (originalText === editorText) { + showUnsavedConfirm = false; + close(); + } + } + + function handleDiscard() { + showUnsavedConfirm = false; + editorText = originalText; + close(); + } + + function handleCloseClick() { + if (dirty) { + showUnsavedConfirm = true; + } else { + close(); + } + } + + function closeguard(): boolean { + if (dirty) { + showUnsavedConfirm = true; + return false; + } + return true; + } + function close() { open = false; } + // Sync parquetShowingRowsCount with the preview data + $effect(() => { + if (preview.kind === 'parquet') { + parquetShowingRowsCount = preview.rows.length; + } + }); + + // When infinite scroll is disabled and user clicks the data tab, + // fetch the first page of parquet data on demand. + $effect(() => { + if ( + preview.kind === 'parquet' && + parquetTab === 'data' && + preview.rows.length === 0 && + !infiniteScrollEnabled && + !parquetDataLoading && + objectKey + ) { + parquetDataLoading = true; + const hdrs = preview.headers; + const rowCount = Math.min(250, preview.totalRows); + const rows: unknown[][] = Array.from({ length: rowCount }, () => + new Array(hdrs.length).fill(undefined) + ); + preview = { ...preview, rows: rows.map((r) => [...r]) }; + const colPos: Record = {}; + fetchParquetRows(0, rowCount, (name, values) => { + const colIdx = hdrs.indexOf(name); + if (colIdx < 0) return; + let pos = colPos[name] ?? 0; + for (let i = 0; i < values.length; i++) { + while (rows.length <= pos) { + rows.push(new Array(hdrs.length).fill(undefined)); + } + rows[pos][colIdx] = values[i]; + pos++; + } + colPos[name] = pos; + if (preview.kind === 'parquet') { + preview = { ...preview, rows: rows.map((r) => [...r]) }; + } + }) + .then((finalRows) => { + parquetDataLoading = false; + if (preview.kind === 'parquet') { + preview = { + ...preview, + rows: finalRows, + truncated: finalRows.length < preview.totalRows + }; + } + }) + .catch(() => { + parquetDataLoading = false; + }); + } + }); + function toggleMaximized() { maximized = !maximized; } + + // Function to fetch additional parquet chunks during infinite scroll + async function fetchParquetRows( + offset: number, + limit: number, + onColumn: ((name: string, values: unknown[]) => void) | undefined + ): Promise { + if (!objectKey) return []; + + const res = await storage.api.preview({ + bucket, + key: objectKey, + offset, + limit, + data: true + }); + + if (!res.ok) { + throw new Error('Failed to fetch parquet chunk'); + } + + const { rows } = await readNdjsonStream(res, onColumn); + return rows; + } + + // Function to fetch additional CSV chunks during infinite scroll + async function fetchCsvRows(offset: number, limit: number): Promise { + if (!objectKey) return []; + + const res = await storage.api.preview({ + bucket, + key: objectKey, + offset, + limit, + data: true + }); + + if (!res.ok) { + throw new Error('Failed to fetch CSV chunk'); + } + + const { rows, totalRows } = await readCsvNdjsonStream(res); + csvTotalRows = totalRows; + return rows; + } - + + {#if maximized} - {@const MaximizeIcon = IconCloseFullscreen} + + + + {:else} + + + + {/if} + + - {:else} - {@const MaximizeIcon = IconOpenInFull} + +
+ + {#if preview.kind === 'parquet'} +
- {/if} - -
+ +
+ {/if} -
+
{#if preview.kind === 'idle' || preview.kind === 'loading'}
{m.storage_preview_error_title()}

{preview.message}

+ {:else if preview.kind === 'parquet'} + +
+ +
+ +
+ {#if preview.dataBlocked} +
+
+ {:else} +
{:else if preview.kind === 'text'} - +
+ {#if !editorReady} +
+ + {m.storage_preview_loading()} +
+ {/if} + {#if readOnlyMessage} +
+
+ {/if} +
+ +
+
{:else if preview.kind === 'csv'} + + {:else if preview.kind === 'csv_scroll'} - {:else if preview.kind === 'parquet'} - {:else if preview.kind === 'image'} - +
+ +
{:else if preview.kind === 'pdf'} - +
+ +
{:else if preview.kind === 'fallback'} - +
+ +
{/if}
- - {#if preview.kind === 'text' || preview.kind === 'csv' || preview.kind === 'parquet' || preview.kind === 'image' || preview.kind === 'pdf'} + {#if preview.kind === 'text' || preview.kind === 'csv' || preview.kind === 'csv_scroll' || preview.kind === 'parquet' || preview.kind === 'image' || preview.kind === 'pdf'}
- {#if (preview.kind === 'text' || preview.kind === 'csv') && preview.truncated} + {#if preview.kind === 'parquet' && preview.dataBlocked} - {:else if preview.kind === 'image' || preview.kind === 'pdf'} + {:else if !archiveKey && (preview.kind === 'text' || preview.kind === 'csv' || preview.kind === 'csv_scroll') && preview.truncated} + + {:else if !archiveKey && (preview.kind === 'image' || preview.kind === 'pdf')} {/if} - + {#if preview.kind === 'text' && !readOnlyMessage} + + + {/if}
{/if}
+ + + (showUnsavedConfirm = false)} +/> diff --git a/src/lib/components/storage/modals/PreviewModal.svelte.spec.ts b/src/lib/components/storage/modals/PreviewModal.svelte.spec.ts index 9dfa4408..308fa75e 100644 --- a/src/lib/components/storage/modals/PreviewModal.svelte.spec.ts +++ b/src/lib/components/storage/modals/PreviewModal.svelte.spec.ts @@ -1,93 +1,100 @@ import { page } from 'vitest/browser'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from 'vitest-browser-svelte'; import { faker } from '@faker-js/faker'; import PreviewModal from './PreviewModal.svelte'; +// Prevent Monaco Editor from loading in tests by making $app/environment's +// `browser` return false. TextEditor renders a plain
 as fallback.
+vi.mock('$app/environment', () => ({ browser: false }));
+
 // Mock $app/paths
 vi.mock('$app/paths', () => ({
   resolve: (path: string) => path
 }));
 
+// Mock storage context — provide an API that delegates to the global fetch
+vi.mock('$lib/storage/context.js', () => ({
+  getStorageState: () => ({
+    get api() {
+      return {
+        preview: (...args: unknown[]) =>
+          (globalThis.fetch as typeof fetch)(...(args as Parameters)),
+        archiveExtract: (...args: unknown[]) =>
+          (globalThis.fetch as typeof fetch)(...(args as Parameters)),
+        saveText: (...args: unknown[]) =>
+          (globalThis.fetch as typeof fetch)(...(args as Parameters))
+      };
+    },
+    bucket: 'test-bucket'
+  })
+}));
+
 const defaultProps = {
   open: true,
   bucket: faker.word.noun(),
   objectKey: 'path/to/document.txt'
 };
 
-/** Helper to create a mock Response with given content-type and headers */
 function mockFetchResponse(
   body: BodyInit | null,
-  options: {
+  opts?: {
     contentType?: string;
-    format?: string;
     truncated?: boolean;
     totalSize?: number;
     previewBytes?: number;
-    totalRows?: number;
-    previewRows?: number;
     renderable?: boolean;
-    status?: number;
-  } = {}
-) {
-  const headers: Record = {
-    'Content-Type': options.contentType ?? 'text/plain',
-    'X-Preview-Format': options.format ?? 'text',
-    'X-Preview-Truncated': String(options.truncated ?? false),
-    'X-Preview-Total-Size': String(options.totalSize ?? 0),
-    'X-Preview-Bytes': String(options.previewBytes ?? 0)
-  };
-  if (options.totalRows !== undefined) {
-    headers['X-Preview-Total-Rows'] = String(options.totalRows);
-  }
-  if (options.previewRows !== undefined) {
-    headers['X-Preview-Preview-Rows'] = String(options.previewRows);
+    etag?: string;
   }
-  if (options.renderable === false) {
-    headers['X-Preview-Renderable'] = 'false';
-  }
-  return new Response(body, { status: options.status ?? 200, headers });
+): Response {
+  return new Response(body, {
+    status: 200,
+    headers: {
+      'Content-Type': opts?.contentType ?? 'text/plain',
+      'X-Preview-Format': 'text',
+      'X-Preview-Truncated': String(opts?.truncated ?? false),
+      'X-Preview-Total-Size': String(opts?.totalSize ?? 11),
+      'X-Preview-Bytes': String(opts?.previewBytes ?? 11),
+      ...(opts?.etag ? { 'X-Preview-ETag': opts.etag } : {})
+    }
+  });
 }
 
-describe('PreviewModal', () => {
+describe('PreviewModal basics', () => {
+  const fetchMock = vi.fn();
+
   beforeEach(() => {
-    vi.stubGlobal(
-      'fetch',
-      vi.fn().mockResolvedValue(
-        mockFetchResponse('Hello world', {
-          contentType: 'text/plain',
-          totalSize: 11,
-          previewBytes: 11
-        })
-      )
-    );
+    vi.stubGlobal('fetch', fetchMock);
+    fetchMock.mockResolvedValue(mockFetchResponse('Hello world'));
+  });
+
+  afterEach(() => {
+    cleanup();
+    vi.unstubAllGlobals();
+    fetchMock.mockReset();
   });
 
   describe('initial render', () => {
     it('should render a dialog when open', async () => {
       render(PreviewModal, defaultProps);
-
       await expect.element(page.getByRole('dialog')).toBeInTheDocument();
     });
 
     it('should show the filename in the heading', async () => {
       render(PreviewModal, { ...defaultProps, objectKey: 'folder/report.csv' });
-
       await expect.element(page.getByText('report.csv')).toBeInTheDocument();
     });
 
     it('should show loading state initially', async () => {
-      vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
+      fetchMock.mockReturnValue(new Promise(() => {}));
       render(PreviewModal, defaultProps);
-
-      await expect.element(page.getByText('Loading preview…')).toBeInTheDocument();
+      await expect.element(page.getByText('Loading preview\u2026')).toBeInTheDocument();
     });
   });
 
   describe('when open is false', () => {
     it('should not render dialog content', async () => {
       render(PreviewModal, { ...defaultProps, open: false });
-
       const heading = page.getByRole('heading');
       await expect.element(heading).not.toBeInTheDocument();
     });
@@ -96,7 +103,6 @@ describe('PreviewModal', () => {
   describe('close button', () => {
     it('should have a close button', async () => {
       render(PreviewModal, defaultProps);
-
       const closeBtn = page.getByRole('button', { name: /close/i });
       await expect.element(closeBtn).toBeInTheDocument();
     });
@@ -105,25 +111,20 @@ describe('PreviewModal', () => {
   describe('maximize toggle', () => {
     it('should have a maximise button', async () => {
       render(PreviewModal, defaultProps);
-
       const maxBtn = page.getByRole('button', { name: /maximise/i });
       await expect.element(maxBtn).toBeInTheDocument();
     });
 
     it('should switch to restore button after clicking maximise', async () => {
       render(PreviewModal, defaultProps);
-
       await page.getByRole('button', { name: /maximise/i }).click();
-
       await expect.element(page.getByRole('button', { name: /restore/i })).toBeInTheDocument();
     });
 
     it('should toggle back to maximise after clicking restore', async () => {
       render(PreviewModal, defaultProps);
-
       await page.getByRole('button', { name: /maximise/i }).click();
       await page.getByRole('button', { name: /restore/i }).click();
-
       await expect.element(page.getByRole('button', { name: /maximise/i })).toBeInTheDocument();
     });
   });
@@ -273,45 +274,276 @@ describe('PreviewModal', () => {
   });
 
   describe('parquet preview', () => {
-    it('should render parquet preview when format header is parquet', async () => {
+    /** Create a mock Response-like object whose body yields NDJSON lines. */
+    function ndjsonResponse(
+      messages: Record[],
+      options: {
+        contentType?: string;
+        format?: string;
+        truncated?: boolean;
+        totalSize?: number;
+        totalRows?: number;
+        previewRows?: number;
+        previewBytes?: number;
+        dataBlocked?: boolean;
+      }
+    ): Response {
+      const ndjson = messages.map((m) => JSON.stringify(m)).join('\n') + '\n';
+      const headerMap: Record = {
+        'content-type': options.contentType ?? 'application/json',
+        'x-preview-format': options.format ?? 'parquet',
+        'x-preview-truncated': String(options.truncated ?? false),
+        'x-preview-total-size': String(options.totalSize ?? 0),
+        'x-preview-bytes': String(options.previewBytes ?? 0),
+        'x-preview-renderable': 'true'
+      };
+      if (options.dataBlocked) {
+        headerMap['x-preview-data-blocked'] = 'true';
+      }
+      if (options.totalRows !== undefined) {
+        headerMap['x-preview-total-rows'] = String(options.totalRows);
+      }
+      if (options.previewRows !== undefined) {
+        headerMap['x-preview-preview-rows'] = String(options.previewRows);
+      }
+      const encoded = new TextEncoder().encode(ndjson);
+      const mockResponse = {
+        ok: true,
+        status: 200,
+        headers: { get: (name: string) => headerMap[name.toLowerCase()] ?? null },
+        body: {
+          getReader() {
+            let done = false;
+            return {
+              read() {
+                if (done) return Promise.resolve({ done: true, value: undefined });
+                done = true;
+                return Promise.resolve({ done: false, value: encoded });
+              },
+              cancel() {}
+            };
+          }
+        },
+        json: async () => ({ error: 'not available' })
+      };
+      return mockResponse as unknown as Response;
+    }
+
+    it('should render metadata tab by default and show schema info', async () => {
       vi.stubGlobal(
         'fetch',
         vi.fn().mockResolvedValue(
-          mockFetchResponse('col1,col2\nval1,val2', {
-            contentType: 'text/csv',
-            format: 'parquet',
-            truncated: false,
-            totalSize: 5000,
-            totalRows: 100,
-            previewRows: 50
-          })
+          ndjsonResponse(
+            [
+              {
+                t: 'h',
+                h: ['col1', 'col2'],
+                tr: 100,
+                s: [
+                  {
+                    name: 'col1',
+                    type: 'string',
+                    codec: 'SNAPPY',
+                    compressedSize: 100,
+                    uncompressedSize: 200,
+                    stats: { nullCount: 0, distinctCount: null, min: null, max: null }
+                  },
+                  {
+                    name: 'col2',
+                    type: 'int64',
+                    codec: 'SNAPPY',
+                    compressedSize: 50,
+                    uncompressedSize: 80,
+                    stats: { nullCount: null, distinctCount: null, min: null, max: null }
+                  }
+                ],
+                m: {
+                  rowGroups: 1,
+                  compressionCodecs: ['SNAPPY'],
+                  compressionUniform: true,
+                  hasOffsetIndex: true,
+                  hasColumnIndex: true,
+                  createdBy: null,
+                  version: 1,
+                  arrowSchema: null
+                }
+              },
+              { t: 'c', n: 'col1', v: ['val1'] },
+              { t: 'c', n: 'col2', v: ['val2'] }
+            ],
+            {
+              format: 'parquet',
+              truncated: false,
+              totalSize: 5000,
+              totalRows: 100,
+              previewRows: 50
+            }
+          )
         )
       );
       render(PreviewModal, { ...defaultProps, objectKey: 'data/file.parquet' });
 
       await expect.element(page.getByText('file.parquet')).toBeInTheDocument();
-      // Parquet shows a size badge in the header
-      await expect.element(page.getByText('val1')).toBeInTheDocument();
+      // Metadata tab should be active by default showing schema info
+      await expect.element(page.getByText('col1').first()).toBeInTheDocument();
+      await expect.element(page.getByText('col2').first()).toBeInTheDocument();
+      // Tab labels should be visible
+      await expect.element(page.getByText('Metadata')).toBeInTheDocument();
+      await expect
+        .element(page.getByRole('tab', { name: 'Data', exact: true }))
+        .toBeInTheDocument();
     });
 
     it('should show row count badge when parquet is truncated', async () => {
       vi.stubGlobal(
         'fetch',
         vi.fn().mockResolvedValue(
-          mockFetchResponse('col1,col2\nval1,val2', {
-            contentType: 'text/csv',
-            format: 'parquet',
-            truncated: true,
-            totalSize: 50000,
-            totalRows: 10000,
-            previewRows: 500
-          })
+          ndjsonResponse(
+            [
+              {
+                t: 'h',
+                h: ['col1', 'col2'],
+                tr: 10000,
+                s: [],
+                m: {
+                  rowGroups: 1,
+                  compressionCodecs: ['SNAPPY'],
+                  compressionUniform: true,
+                  hasOffsetIndex: true,
+                  hasColumnIndex: true,
+                  createdBy: null,
+                  version: 1,
+                  arrowSchema: null
+                }
+              },
+              { t: 'c', n: 'col1', v: ['val1'] },
+              { t: 'c', n: 'col2', v: ['val2'] }
+            ],
+            {
+              format: 'parquet',
+              truncated: true,
+              totalSize: 50000,
+              totalRows: 10000,
+              previewRows: 500
+            }
+          )
         )
       );
       render(PreviewModal, { ...defaultProps, objectKey: 'data/file.parquet' });
 
       await expect.element(page.getByText('file.parquet')).toBeInTheDocument();
-      await expect.element(page.getByText('val1')).toBeInTheDocument();
+      await expect.element(page.getByText('col1').first()).toBeInTheDocument();
+      await expect
+        .element(page.getByText('Showing first 1 of 10,000 rows (parquet)').first())
+        .toBeInTheDocument();
+    });
+
+    it('should show data table when clicking Data tab', async () => {
+      vi.stubGlobal(
+        'fetch',
+        vi.fn().mockResolvedValue(
+          ndjsonResponse(
+            [
+              {
+                t: 'h',
+                h: ['col1', 'col2'],
+                tr: 100,
+                s: [],
+                m: {
+                  rowGroups: 1,
+                  compressionCodecs: ['SNAPPY'],
+                  compressionUniform: true,
+                  hasOffsetIndex: true,
+                  hasColumnIndex: true,
+                  createdBy: null,
+                  version: 1,
+                  arrowSchema: null
+                }
+              },
+              { t: 'c', n: 'col1', v: ['val1'] },
+              { t: 'c', n: 'col2', v: ['val2'] }
+            ],
+            {
+              format: 'parquet',
+              truncated: false,
+              totalSize: 5000,
+              totalRows: 100,
+              previewRows: 50
+            }
+          )
+        )
+      );
+      render(PreviewModal, { ...defaultProps, objectKey: 'data/file.parquet' });
+
+      await expect.element(page.getByText('file.parquet')).toBeInTheDocument();
+
+      // Click the Data tab
+      await page.getByRole('tab', { name: 'Data' }).first().click();
+
+      // Parquet preview table should be visible with column headers
+      await expect
+        .element(page.getByRole('table', { name: 'Parquet preview' }))
+        .toBeInTheDocument();
+      await expect.element(page.getByText('col1').first()).toBeInTheDocument();
+      await expect.element(page.getByText('col2').first()).toBeInTheDocument();
+    });
+
+    it('should show blocked message when parquet is not renderable', async () => {
+      vi.stubGlobal(
+        'fetch',
+        vi.fn().mockResolvedValue(
+          ndjsonResponse(
+            [
+              {
+                t: 'h',
+                h: ['col1', 'col2'],
+                tr: 100,
+                s: [
+                  {
+                    name: 'col1',
+                    type: 'string',
+                    codec: 'SNAPPY',
+                    compressedSize: 100,
+                    uncompressedSize: 200,
+                    stats: { nullCount: null, distinctCount: null, min: null, max: null }
+                  },
+                  {
+                    name: 'col2',
+                    type: 'int64',
+                    codec: 'GZIP',
+                    compressedSize: 50,
+                    uncompressedSize: 80,
+                    stats: { nullCount: null, distinctCount: null, min: null, max: null }
+                  }
+                ],
+                m: {
+                  rowGroups: 1,
+                  compressionCodecs: ['SNAPPY', 'GZIP'],
+                  compressionUniform: false,
+                  hasOffsetIndex: true,
+                  hasColumnIndex: true,
+                  createdBy: null,
+                  version: 1,
+                  arrowSchema: null
+                }
+              }
+            ],
+            {
+              format: 'parquet',
+              dataBlocked: true,
+              truncated: false,
+              totalSize: 5000,
+              totalRows: 100,
+              previewRows: 50
+            }
+          )
+        )
+      );
+
+      render(PreviewModal, { ...defaultProps, objectKey: 'data/blocked.parquet' });
+
+      await expect.element(page.getByText('blocked.parquet')).toBeInTheDocument();
+      await expect.element(page.getByText('Preview blocked').first()).toBeInTheDocument();
     });
   });
 
@@ -382,6 +614,57 @@ describe('PreviewModal', () => {
   });
 
   describe('fallback preview', () => {
+    it.each([
+      ['archive.rar', 'application/octet-stream'],
+      ['ARCHIVE.RAR', 'binary/octet-stream'],
+      ['archive', 'application/vnd.rar'],
+      ['archive', 'application/x-rar-compressed'],
+      ['archive', 'application/x-rar']
+    ])('should show the RAR fallback for %s (%s)', async (objectKey, contentType) => {
+      vi.stubGlobal(
+        'fetch',
+        vi
+          .fn()
+          .mockResolvedValue(
+            mockFetchResponse(new Uint8Array([0x52, 0x61, 0x72, 0x21]), { contentType })
+          )
+      );
+      render(PreviewModal, { ...defaultProps, objectKey });
+
+      await expect.element(page.getByText('RAR archives are not supported.')).toBeInTheDocument();
+      await expect
+        .element(page.getByRole('button', { name: /download full/i }))
+        .toBeInTheDocument();
+      await expect
+        .element(
+          page.getByText(
+            'This file appears to contain binary data and cannot be displayed as text.'
+          )
+        )
+        .not.toBeInTheDocument();
+    });
+
+    it('should show the unsupported warning for 7zip files', async () => {
+      vi.stubGlobal(
+        'fetch',
+        vi.fn().mockResolvedValue(
+          mockFetchResponse(new Uint8Array([0x37, 0x7a, 0xbc, 0xaf]), {
+            contentType: 'application/x-compressed'
+          })
+        )
+      );
+      render(PreviewModal, { ...defaultProps, objectKey: 'archive.7z' });
+
+      await expect.element(page.getByText('7zip archives are not supported.')).toBeInTheDocument();
+      await expect
+        .element(
+          page.getByText(
+            'This file appears to contain binary data and cannot be displayed as text.'
+          )
+        )
+        .not.toBeInTheDocument();
+    });
+
     it('should render fallback when X-Preview-Renderable is false', async () => {
       vi.stubGlobal(
         'fetch',
@@ -472,6 +755,57 @@ describe('PreviewModal', () => {
     });
   });
 
+  describe('editing safeguards', () => {
+    const saveButton = () => page.getByRole('button', { name: 'Save', exact: true });
+
+    it('offers saving for complete UTF-8 content with an ETag', async () => {
+      fetchMock.mockResolvedValue(mockFetchResponse('Hello world', { etag: '"e1"' }));
+      render(PreviewModal, defaultProps);
+
+      await expect.element(page.getByText('Hello world')).toBeInTheDocument();
+      await expect.element(saveButton()).toBeInTheDocument();
+      await expect.element(page.getByTestId('editor-readonly-notice')).not.toBeInTheDocument();
+    });
+
+    it('makes truncated content read-only even below the edit limit', async () => {
+      fetchMock.mockResolvedValue(
+        mockFetchResponse('Partial content', {
+          truncated: true,
+          totalSize: 50000,
+          previewBytes: 1024,
+          etag: '"e1"'
+        })
+      );
+      render(PreviewModal, defaultProps);
+
+      await expect
+        .element(page.getByText(/only the beginning of this file was loaded/i))
+        .toBeInTheDocument();
+      await expect.element(saveButton()).not.toBeInTheDocument();
+    });
+
+    it('makes UTF-16 content read-only', async () => {
+      const bytes = new Uint8Array([0xff, 0xfe, 0x48, 0x00, 0x69, 0x00]);
+      fetchMock.mockResolvedValue(
+        mockFetchResponse(new Blob([bytes]), { totalSize: 6, previewBytes: 6, etag: '"e1"' })
+      );
+      render(PreviewModal, defaultProps);
+
+      await expect.element(page.getByText(/is not UTF-8 text/i)).toBeInTheDocument();
+      await expect.element(saveButton()).not.toBeInTheDocument();
+    });
+
+    it('makes content without an ETag read-only', async () => {
+      fetchMock.mockResolvedValue(mockFetchResponse('Hello world'));
+      render(PreviewModal, defaultProps);
+
+      await expect
+        .element(page.getByText(/version of this file could not be determined/i))
+        .toBeInTheDocument();
+      await expect.element(saveButton()).not.toBeInTheDocument();
+    });
+  });
+
   describe('UTF-16 BOM detection', () => {
     it('should decode UTF-16 LE BOM content', async () => {
       // UTF-16 LE BOM (0xFF 0xFE) followed by "Hi" in UTF-16 LE
@@ -494,7 +828,7 @@ describe('PreviewModal', () => {
       );
       render(PreviewModal, defaultProps);
 
-      await expect.element(page.getByText('Hi')).toBeInTheDocument();
+      await expect.element(page.getByText('Hi', { exact: true })).toBeInTheDocument();
     });
 
     it('should decode UTF-16 BE BOM content', async () => {
@@ -575,13 +909,11 @@ describe('PreviewModal', () => {
   describe('accessibility', () => {
     it('should have a dialog role', async () => {
       render(PreviewModal, defaultProps);
-
       await expect.element(page.getByRole('dialog')).toBeInTheDocument();
     });
 
     it('should have aria-label on close button', async () => {
       render(PreviewModal, defaultProps);
-
       const closeBtn = page.getByRole('button', { name: /close/i });
       await expect.element(closeBtn).toBeInTheDocument();
     });
@@ -590,7 +922,6 @@ describe('PreviewModal', () => {
   describe('edge cases', () => {
     it('should handle null objectKey', async () => {
       render(PreviewModal, { ...defaultProps, objectKey: null });
-
       await expect.element(page.getByRole('dialog')).toBeInTheDocument();
     });
 
@@ -599,7 +930,6 @@ describe('PreviewModal', () => {
         ...defaultProps,
         objectKey: 'a/b/c/d/e/f/deeply-nested-file.json'
       });
-
       await expect.element(page.getByText('deeply-nested-file.json')).toBeInTheDocument();
     });
 
@@ -608,7 +938,6 @@ describe('PreviewModal', () => {
         ...defaultProps,
         objectKey: 'data/file (copy).txt'
       });
-
       await expect.element(page.getByText('file (copy).txt')).toBeInTheDocument();
     });
   });
diff --git a/src/lib/components/storage/modals/RenameModal.svelte b/src/lib/components/storage/modals/RenameModal.svelte
new file mode 100644
index 00000000..6bad29b9
--- /dev/null
+++ b/src/lib/components/storage/modals/RenameModal.svelte
@@ -0,0 +1,121 @@
+
+
+ !loading}>
+  
+
diff --git a/src/lib/components/storage/modals/RenameModal.svelte.spec.ts b/src/lib/components/storage/modals/RenameModal.svelte.spec.ts
new file mode 100644
index 00000000..bdbb2220
--- /dev/null
+++ b/src/lib/components/storage/modals/RenameModal.svelte.spec.ts
@@ -0,0 +1,71 @@
+import { page, userEvent } from 'vitest/browser';
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import RenameModal from './RenameModal.svelte';
+
+function renderModal(currentName = 'report.csv') {
+  const onConfirm = vi.fn();
+  const onCancel = vi.fn();
+  render(RenameModal, { open: true, currentName, onConfirm, onCancel });
+  const message = document.querySelector('.modal-box [aria-live="polite"]')!;
+  return { onConfirm, onCancel, input: page.getByLabelText('New name'), message };
+}
+
+describe('RenameModal', () => {
+  it('explains an invalid name at the input instead of closing', async () => {
+    const { onConfirm, onCancel, input, message } = renderModal();
+
+    await input.fill('a/b.csv');
+
+    await expect.element(message).toHaveTextContent(/single name without "\/"/);
+    await expect.element(input).toHaveAttribute('aria-invalid', 'true');
+    await expect.element(input).toHaveAttribute('aria-describedby', message.id);
+    await expect.element(page.getByRole('button', { name: 'Rename' })).toBeDisabled();
+
+    await userEvent.keyboard('{Enter}');
+    expect(onConfirm).not.toHaveBeenCalled();
+    expect(onCancel).not.toHaveBeenCalled();
+  });
+
+  it('explains reserved characters, relative segments and empty names', async () => {
+    const { input, message } = renderModal();
+
+    await input.fill('x?y');
+    await expect.element(message).toHaveTextContent(/without the characters/);
+
+    await input.fill('..');
+    await expect.element(message).toHaveTextContent(/not "\." or "\.\."/);
+
+    await input.fill(' ');
+    await expect.element(message).toHaveTextContent('Enter a name.');
+  });
+
+  it('does not flag an unchanged existing name that new names may not use', async () => {
+    const { input, message } = renderModal('what?.csv');
+
+    await expect.element(input).toHaveValue('what?.csv');
+    await expect.poll(() => message.textContent?.trim()).toBe('');
+  });
+
+  it('does not rename an unchanged name with surrounding whitespace', async () => {
+    const { onConfirm, onCancel, input, message } = renderModal(' padded.csv ');
+
+    await expect.element(input).toHaveValue(' padded.csv ');
+    await expect.poll(() => message.textContent?.trim()).toBe('');
+    await expect.element(page.getByRole('button', { name: 'Rename' })).toBeDisabled();
+
+    await input.click();
+    await userEvent.keyboard('{Enter}');
+    expect(onConfirm).not.toHaveBeenCalled();
+    expect(onCancel).toHaveBeenCalled();
+  });
+
+  it('submits a valid trimmed name', async () => {
+    const { onConfirm, input } = renderModal();
+
+    await input.fill(' renamed.csv ');
+    await page.getByRole('button', { name: 'Rename' }).click();
+
+    expect(onConfirm).toHaveBeenCalledWith('renamed.csv');
+  });
+});
diff --git a/src/lib/components/storage/modals/StorageModals.svelte b/src/lib/components/storage/modals/StorageModals.svelte
index a3d8f9f0..8e3f2bf1 100644
--- a/src/lib/components/storage/modals/StorageModals.svelte
+++ b/src/lib/components/storage/modals/StorageModals.svelte
@@ -1,27 +1,40 @@
 
 
 {#if storage.activeModal?.type === 'delete'}
@@ -53,8 +104,11 @@
 {#if storage.activeModal?.type === 'preview'}
   
 {/if}
 
@@ -63,6 +117,76 @@
     bind:open={uploadOpen}
     bucket={storage.activeModal.payload.bucket}
     prefix={storage.activeModal.payload.prefix}
+    initialFiles={storage.activeModal.payload.files}
     onSuccess={storage.handleUploadSuccess}
   />
 {/if}
+
+{#if storage.activeModal?.type === 'details'}
+  
+{/if}
+
+{#if storage.activeModal?.type === 'rename'}
+  {@const modalPayload = storage.activeModal.payload}
+  {@const renameFolder = modalPayload.key.endsWith('/')}
+  {@const renameName = keyToName(modalPayload.key)}
+   storage.confirmRename(modalPayload.key, newName)}
+    onCancel={() => {
+      storage.renameError = null;
+      storage.renameLoading = false;
+      storage.closeModal();
+    }}
+    loading={storage.renameLoading}
+    error={storage.renameError}
+  />
+{/if}
+
+{#if storage.activeModal?.type === 'confirm-move'}
+  {@const modalPayload = storage.activeModal.payload}
+  
+{/if}
+
+{#if storage.activeModal?.type === 'create'}
+  {@const modalPayload = storage.activeModal.payload}
+   storage.confirmCreate(name, modalPayload.type)}
+    onCancel={storage.cancelCreate}
+  />
+{/if}
+
+{#if storage.activeModal?.type === 'resolve-conflicts'}
+  {@const modalPayload = storage.activeModal.payload}
+   storage.checkConflictRename(entry)}
+    onConfirm={(entries) => storage.confirmConflictResolution(entries)}
+    onCancel={() => storage.cancelConflictResolution()}
+  />
+{/if}
diff --git a/src/lib/components/storage/modals/StorageModals.svelte.spec.ts b/src/lib/components/storage/modals/StorageModals.svelte.spec.ts
index 877b38cf..54338ee0 100644
--- a/src/lib/components/storage/modals/StorageModals.svelte.spec.ts
+++ b/src/lib/components/storage/modals/StorageModals.svelte.spec.ts
@@ -9,17 +9,16 @@ vi.mock('$app/paths', () => ({
   resolve: (path: string) => path
 }));
 
+// Mock $app/navigation for UploadModal (the real one needs the full $app/paths)
+vi.mock('$app/navigation', () => ({
+  beforeNavigate: vi.fn(),
+  goto: vi.fn()
+}));
+
 // Mock upload module
 vi.mock('$lib/storage/upload.js', () => ({
   checkObjectExists: vi.fn().mockResolvedValue(false),
-  uploadFile: vi.fn().mockResolvedValue(undefined),
-  UploadError: class UploadError extends Error {
-    code: string;
-    constructor(code: string, message: string) {
-      super(message);
-      this.code = code;
-    }
-  }
+  uploadFile: vi.fn().mockResolvedValue(undefined)
 }));
 
 function createMockStorageState(activeModal: unknown = null): StorageState {
diff --git a/src/lib/components/storage/modals/UnsavedConfirmDialog.svelte b/src/lib/components/storage/modals/UnsavedConfirmDialog.svelte
new file mode 100644
index 00000000..8e5579d8
--- /dev/null
+++ b/src/lib/components/storage/modals/UnsavedConfirmDialog.svelte
@@ -0,0 +1,39 @@
+
+
+
+  
+
diff --git a/src/lib/components/storage/modals/details/BucketDetails.svelte b/src/lib/components/storage/modals/details/BucketDetails.svelte
new file mode 100644
index 00000000..842f3824
--- /dev/null
+++ b/src/lib/components/storage/modals/details/BucketDetails.svelte
@@ -0,0 +1,297 @@
+
+
+{#snippet unavailable()}
+  
+
+{/snippet} + +
+ {#if loading} +
+ +
+ {/if} + + {#if error} + + {/if} + + {#if details} +
+

+ {m.storage_details_versioning()} +

+
+ {#if details.versioning === null} + {@render unavailable()} + {:else if details.versioning === 'Enabled'} + {versioningLabel(details.versioning)} + {:else if details.versioning === 'Suspended'} + {versioningLabel(details.versioning)} + {:else} + {versioningLabel(details.versioning)} + {/if} +
+
+ +
+

+ {m.storage_details_owner()} +

+ {#if details.acl} +
+
+ {:else} + {@render unavailable()} + {/if} +
+ + {#if details.acl && details.acl.grants.length > 0} +
+

+ {m.storage_details_permissions()} +

+
+ {#each details.acl.grants as grant (grant.grantee + grant.permission)} +
+
+ {/each} +
+
+ {/if} + + {#if details.lifecycleRules === null} +
+

+ {m.storage_details_lifecycle_rules()} +

+ {@render unavailable()} +
+ {:else if details.lifecycleRules.length > 0} +
+

+ {m.storage_details_lifecycle_rules()} +

+
+ {#each details.lifecycleRules as rule (rule.id)} +
+
+ {rule.id || m.storage_details_unnamed_rule()} + + {rule.status} + +
+ +
+ {m.storage_details_lifecycle_filter()}: + {#if rule.filter && typeof rule.filter === 'object' && 'prefix' in (rule.filter as Record) && (rule.filter as Record).prefix} + + {m.storage_details_lifecycle_prefix_filter({ + prefix: String((rule.filter as Record).prefix) + })} + + {:else if rule.filter && typeof rule.filter === 'object' && 'and' in (rule.filter as Record)} + + AND ({(rule.filter as Record).and as string}) + + {:else if rule.filter && typeof rule.filter === 'object' && Object.keys(rule.filter).length > 0} + + {JSON.stringify(rule.filter)} + + {:else} + {m.storage_details_lifecycle_all_objects()} + {/if} +
+ + {#if rule.transitions.length > 0} +
+
+ {/if} + + {#if rule.expirations.length > 0} +
+
+ {/if} + + {#if rule.noncurrentVersionTransitions.length > 0} +
+
+ {/if} + + {#if rule.noncurrentVersionExpirations.length > 0} +
+
+ {/if} + + {#if rule.abortIncompleteMultipartUploads.length > 0} +
+
+ {/if} +
+ {/each} +
+
+ {:else if !loading} +
+
+ {/if} + + {#if details.tags === null} +
+

+ {m.storage_details_tags()} +

+ {@render unavailable()} +
+ {:else if Object.keys(details.tags).length > 0} +
+

+ {m.storage_details_tags()} +

+
+ {#each Object.entries(details.tags) as [key, value] (key)} + + {key}: {value} + + {/each} +
+
+ {:else if !loading} +
{m.storage_details_no_tags()}
+ {/if} + {/if} +
diff --git a/src/lib/components/storage/modals/details/DirectoryDetails.svelte b/src/lib/components/storage/modals/details/DirectoryDetails.svelte new file mode 100644 index 00000000..e8ac777e --- /dev/null +++ b/src/lib/components/storage/modals/details/DirectoryDetails.svelte @@ -0,0 +1,380 @@ + + +
+ {#if meta} +
+ + + {#if meta.markerExists} + + + + + {:else} + + + + + {/if} + {#if meta.markerLastModified} + + + + + {/if} + {#if meta.markerStorageClass} + + + + + {/if} + {#if meta.markerVersionId} + + + + + {/if} + {#if meta.markerServerSideEncryption} + + + + + {/if} + {#if meta.markerObjectLockMode} + + + + + {/if} + {#if meta.markerObjectLockRetainUntilDate} + + + + + {/if} + + + + + {#if meta.bucketGrants && meta.bucketGrants.length > 0} + + + + + {/if} + +
{m.storage_details_marker_exists()}{keyToName(prefix)}
{m.storage_details_marker_exists()}{m.storage_details_marker_none()}
{m.storage_details_last_modified()}
{m.storage_details_storage_class()}{meta.markerStorageClass}
{m.storage_details_version_id()}{meta.markerVersionId.slice(0, 20)}...
{m.storage_details_encryption()}{meta.markerServerSideEncryption}
{m.storage_details_object_lock_mode()}{meta.markerObjectLockMode}
{m.storage_details_object_lock_until()}
{m.storage_details_owner()} + {#if meta.bucketOwner === null} + + + {:else} + {meta.bucketOwner} + {/if} +
{m.storage_details_permissions()} +
+ {#each meta.bucketGrants as grant (grant.grantee + '-' + grant.permission)} + + {grant.grantee} + | + {grant.permission} + + {/each} +
+
+
+ {:else if metaError} + + {:else} +
+ +
+ {/if} + + {#if !result && !calculating} + + {/if} + + {#if calculating} +
+ +

{m.storage_details_calculating()}

+

{m.storage_details_calculating_desc()}

+ {#if progress} +
+

+ {m.storage_details_keys_found({ count: progress.keysFound })} +

+

+ {formatFileSize(progress.totalSize)} +

+
+ {/if} +
+ {/if} + + {#if error} + + {/if} + + +
+ {#if result?.truncated} +
+
+ {/if} +
+ + {#if result} +
+
+
+
{m.storage_details_total_size()}
+
{formatFileSize(result.totalSize)}
+
+
+
{m.storage_details_file_count({ count: result.totalFiles })}
+
{result.totalFiles.toLocaleString(getLocale())}
+
+ {#if result.totalDirectories > 0} +
+
+ {m.storage_details_folder_count({ count: result.totalDirectories })} +
+
+ {result.totalDirectories.toLocaleString(getLocale())} +
+
+ {/if} +
+
+ + {#if result.tree.children && result.tree.children.length > 0} +
+ + +
+
+ + +
+
+
+ + +
+ +
+ {/if} + {/if} +
diff --git a/src/lib/components/storage/modals/details/DirectorySizeList.svelte b/src/lib/components/storage/modals/details/DirectorySizeList.svelte new file mode 100644 index 00000000..e3d7e3ad --- /dev/null +++ b/src/lib/components/storage/modals/details/DirectorySizeList.svelte @@ -0,0 +1,161 @@ + + +
+ + + + + + + + + + + {#each sortedItems as item (item.name)} + + + + + + + {/each} + + {#if list.omittedCount > 0} + + + + + + + + + {/if} +
+ + + + + +
+ {item.name} + + {formatFileSize(item.size)} + + {#if item.lastModified} + + {:else} + — + {/if} +
+ {m.storage_details_list_omitted({ count: list.omittedCount })} + + {formatFileSize(list.omittedSize)} +
+
diff --git a/src/lib/components/storage/modals/details/FileDetails.svelte b/src/lib/components/storage/modals/details/FileDetails.svelte new file mode 100644 index 00000000..8a1ffb2a --- /dev/null +++ b/src/lib/components/storage/modals/details/FileDetails.svelte @@ -0,0 +1,99 @@ + + +
+ + + + + + + + + + + + + + + + + + + {#if details.contentType} + + + + + {/if} + {#if details.etag} + + + + + {/if} + + + + + + + + + + + + + +
{m.storage_details_name()}{keyToName(details.key)}
{m.storage_details_file_path()}s3://{bucket}/{details.key}
{m.storage_details_size()}{formatFileSize(details.size)} ({details.size.toLocaleString(getLocale())} bytes)
{m.storage_details_last_modified()}
{m.storage_details_content_type()}{details.contentType}
{m.storage_details_etag()}{details.etag}
{m.storage_details_version_id()} + {details.versionId ?? '—'} +
{m.storage_details_storage_class()}{details.storageClass ?? '—'}
{m.storage_details_is_delete_marker()}{details.isDeleteMarker ? m.storage_details_yes() : m.storage_details_no()}
+ + {#if details.customMetadata && Object.keys(details.customMetadata).length > 0} +

+ {m.storage_details_custom_metadata()} +

+ + + {#each Object.entries(details.customMetadata) as [key, value] (key)} + + + + + {/each} + +
{key}{value}
+ {/if} +
diff --git a/src/lib/components/storage/modals/details/Treemap.svelte b/src/lib/components/storage/modals/details/Treemap.svelte new file mode 100644 index 00000000..33a1c3ba --- /dev/null +++ b/src/lib/components/storage/modals/details/Treemap.svelte @@ -0,0 +1,329 @@ + + +
+ + + {#each rects as rect (rect.x + '-' + rect.y + '-' + rect.w + '-' + rect.h)} + {#if !rect.isContainer && rect.w > 40 && rect.h > 14} + + + + + + + + + {/if} + {/each} + + {#each rects as rect (rect.x + '-' + rect.y + '-' + rect.w + '-' + rect.h)} + openContextMenu(e, rect)} + onkeydown={(e) => { + if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) openContextMenu(e, rect); + }} + > + + + {#if !rect.isContainer && rect.w > 40} + {@const ih = rect.h - 2 * MARGIN} + {#if ih >= 15} + + + {rect.name} + {#if ih >= 38} + {rect.path || './'} + {formatFileSize(rect.size)} + {:else if ih >= 27} + {formatFileSize(rect.size)} + {/if} + + {/if} + {/if} + + {rect.name}{#if rect.path} + ({rect.path}){/if}: {formatFileSize(rect.size)} + + + {/each} + +
+ + + {#if ctxMenu} + + {#if ctxMenu.target.fullKey} + + {/if} + {/if} + diff --git a/src/lib/components/storage/modals/preview/CsvPreview.svelte b/src/lib/components/storage/modals/preview/CsvPreview.svelte index 9281b052..7fdb2401 100644 --- a/src/lib/components/storage/modals/preview/CsvPreview.svelte +++ b/src/lib/components/storage/modals/preview/CsvPreview.svelte @@ -1,93 +1,436 @@ -
- {#if headers.length === 0} -

{m.storage_bucket_empty()}

- {:else} - - - - {#each displayHeaders as header, i (i)} - +{#if isLegacyMode} +
+ {#if textHeaders.length === 0} +

{m.storage_bucket_empty()}

+ {:else} +
{header}
+ + + + {#each textHeaders as header, i (i)} + + {/each} + + + + {#each textRows as row, i (i)} + + + + {#each textHeaders as _h, j (j)} + + {/each} + {/each} - {#if extraColumns > 0} - - {/if} - - - - {#each rows as row, i (i)} - - - {#each displayHeaders as _h, j (j)} - + +
{header}
{(i + 1).toLocaleString()}{row[j] ?? ''}
- {m.storage_preview_csv_columns({ count: extraColumns })} -
{row[j] ?? ''}
+ {#if textTruncated} +

+ {m.storage_preview_csv_rows({ count: textRows.length.toLocaleString(getLocale()) })} +

+ {/if} + {/if} +
+{:else if isSimpleMode} +
+ {#if headers.length === 0} +

{m.storage_bucket_empty()}

+ {:else} +
+ + + + + {#each headers as header (header)} + + {/each} + + + + {#each initialRows as row, i (i)} + + + + {#each headers as _h, j (j)} + + {/each} + {/each} - {#if extraColumns > 0} - + +
{header}
{(i + 1).toLocaleString(getLocale())}{row[j] !== null && row[j] !== undefined ? String(row[j]) : ''}
+
+ {#if isTruncated} +

+ {m.storage_preview_infinite_scroll_disabled()} +

+ {/if} + {/if} +
+{:else} +
+ {#if headers.length === 0} +

{m.storage_bucket_empty()}

+ {:else} + +
{ + scrollTop = e.currentTarget.scrollTop; + scrollLeft = e.currentTarget.scrollLeft; + }} + > + 0 ? `width: ${totalTableWidth}px` : ''} + aria-label={m.storage_preview_csv_label()} + > + + + + {#if leftPadWidth > 0} + + {/if} + {#each headers.slice(colStartIndex, colEndIndex) as header, j (colStartIndex + j)} + + {/each} + {#if rightPadWidth > 0} + + {/if} + + + + {#if startIndex > 0} + + + {/if} - - {/each} - -
0 && columnWidths[colStartIndex + j] !== undefined + ? `width: ${columnWidths[colStartIndex + j]}px` + : 'width: 120px'}>{header}
- {#if truncated} -

- {m.storage_preview_csv_rows({ count: rows.length })} -

+ {#each visibleRows as row (row.index)} + + {(row.index + 1).toLocaleString(getLocale())} + {#if leftPadWidth > 0} + + {/if} + {#if row.data !== null && row.data !== undefined} + + {#each headers.slice(colStartIndex, colEndIndex) as _h, j (colStartIndex + j)} + {#if row.data[colStartIndex + j] !== undefined} + 0 && + columnWidths[colStartIndex + j] !== undefined + ? `width: ${columnWidths[colStartIndex + j]}px` + : 'width: 120px'} + > + {row.data[colStartIndex + j] !== null + ? String(row.data[colStartIndex + j]) + : ''} + + {:else} + +
+ + {/if} + {/each} + {:else} + + {#each headers.slice(colStartIndex, colEndIndex) as _h, j (colStartIndex + j)} + +
+ + {/each} + {/if} + {#if rightPadWidth > 0} + + {/if} + + {/each} + {#if endIndex < virtualTotalRows} + + + + {/if} + + +
+ {#if isTruncated} +

+ {m.storage_preview_csv_rows({ count: formattedLoadedRowsCount })} +

+ {/if} {/if} - {/if} -
+
+{/if} diff --git a/src/lib/components/storage/modals/preview/CsvPreview.svelte.spec.ts b/src/lib/components/storage/modals/preview/CsvPreview.svelte.spec.ts index 4b147b8f..d85f060b 100644 --- a/src/lib/components/storage/modals/preview/CsvPreview.svelte.spec.ts +++ b/src/lib/components/storage/modals/preview/CsvPreview.svelte.spec.ts @@ -5,25 +5,86 @@ import { faker } from '@faker-js/faker'; import CsvPreview from './CsvPreview.svelte'; describe('CsvPreview', () => { - it('should show empty state when text is empty', async () => { + it('should show empty state when input is empty', async () => { render(CsvPreview, { text: '' }); - const table = page.getByRole('table'); - await expect.element(table).not.toBeInTheDocument(); + await expect.element(page.getByText('This bucket is empty')).toBeInTheDocument(); }); - it('should render a table with headers and rows', async () => { - const text = 'Name,Email,City\nAlice,alice@example.com,Berlin\nBob,bob@example.com,Munich'; - render(CsvPreview, { text }); + it('should show empty state when input is just a newline', async () => { + render(CsvPreview, { text: '\n' }); + + await expect.element(page.getByText('This bucket is empty')).toBeInTheDocument(); + }); + + it('should render a table with headers and rows for basic CSV', async () => { + const csv = 'name,city,score\nAlice,Berlin,95\nBob,Munich,88'; + render(CsvPreview, { text: csv }); const table = page.getByRole('table', { name: 'CSV preview' }); await expect.element(table).toBeInTheDocument(); - await expect.element(page.getByText('Name')).toBeInTheDocument(); - await expect.element(page.getByText('Email')).toBeInTheDocument(); + + await expect.element(page.getByRole('cell', { name: 'name' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'city' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'score' })).toBeInTheDocument(); + + await expect.element(page.getByRole('cell', { name: 'Alice' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'Berlin' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: '95' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'Bob' })).toBeInTheDocument(); + }); + + it('should render TSV data correctly using tab separator', async () => { + const tsv = 'name\tcity\tscore\nAlice\tBerlin\t95\nBob\tMunich\t88'; + render(CsvPreview, { text: tsv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + await expect.element(page.getByRole('cell', { name: 'name' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'city' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'score' })).toBeInTheDocument(); + + await expect.element(page.getByRole('cell', { name: 'Alice' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'Berlin' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: '95' })).toBeInTheDocument(); + }); + + it('keeps TSV columns wide enough for their content', async () => { + const tsv = 'name\tdescription\nAlice\tA description that needs horizontal space'; + const { container } = render(CsvPreview, { text: tsv }); + + const table = container.querySelector('table'); + expect(table?.className).toContain('min-w-max'); + }); + + it('should handle CSV with quoted fields containing commas', async () => { + const csv = + 'name,address,country\n"Smith, John","123 Main St, Apt 4",Germany\n"Doe, Jane","456 Oak Ave",Austria'; + render(CsvPreview, { text: csv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + await expect.element(page.getByRole('cell', { name: 'Smith, John' })).toBeInTheDocument(); await expect - .element(page.getByRole('cell', { name: 'Alice', exact: true })) + .element(page.getByRole('cell', { name: '123 Main St, Apt 4' })) .toBeInTheDocument(); - await expect.element(page.getByText('bob@example.com')).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'Germany' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'Doe, Jane' })).toBeInTheDocument(); + }); + + it('should handle TSV with quoted fields containing tabs', async () => { + const tsv = 'name\tnote\nAlice\t"likes\ttabs"\nBob\tplain'; + render(CsvPreview, { text: tsv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + await expect.element(page.getByRole('cell', { name: 'Alice' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'likes tabs' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'Bob' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'plain' })).toBeInTheDocument(); }); it('should handle quoted fields with commas', async () => { @@ -45,7 +106,10 @@ describe('CsvPreview', () => { const text = 'A,B,C\n1,,3\n,,'; render(CsvPreview, { text }); - await expect.element(page.getByText('1')).toBeInTheDocument(); + // nth(1) to skip the line-number cell, get the data cell + await expect + .element(page.getByRole('cell', { name: '1', exact: true }).nth(1)) + .toBeInTheDocument(); await expect.element(page.getByText('3')).toBeInTheDocument(); }); @@ -59,20 +123,7 @@ describe('CsvPreview', () => { render(CsvPreview, { text }); // Should show truncation notice - const notice = page.getByText(/250/); - await expect.element(notice).toBeInTheDocument(); - }); - - it('should respect maxRows prop', async () => { - const header = 'Name,Email'; - const rows = Array.from( - { length: 50 }, - () => `${faker.person.firstName()},${faker.internet.email()}` - ); - const text = [header, ...rows].join('\n'); - render(CsvPreview, { text, maxRows: 10 }); - - const notice = page.getByText('Showing first 10 rows'); + const notice = page.getByText('Showing first 250 rows'); await expect.element(notice).toBeInTheDocument(); }); @@ -94,14 +145,21 @@ describe('CsvPreview', () => { render(CsvPreview, { text }); // Row has only 1 field but 3 headers, so columns B and C should render as empty via ?? '' - await expect.element(page.getByRole('cell', { name: '1', exact: true })).toBeInTheDocument(); - // There should be 3 cells in the body row + await expect + .element(page.getByRole('cell', { name: '1', exact: true }).nth(1)) + .toBeInTheDocument(); + // There should be 3 data cells + 1 line number cell in the body row const rows = page.getByRole('row'); // row 0 is header, row 1 is data const dataCells = rows.nth(1).getByRole('cell'); + // Line number cell await expect.element(dataCells.nth(0)).toHaveTextContent('1'); - await expect.element(dataCells.nth(1)).toHaveTextContent(''); - await expect.element(dataCells.nth(2)).toHaveTextContent(''); + // Column A: value '1' + await expect.element(dataCells.nth(1)).toHaveTextContent('1'); + // Column B: missing, renders as empty + await expect.element(dataCells.nth(2)).toHaveTextContent(/^$/); + // Column C: missing, renders as empty + await expect.element(dataCells.nth(3)).toHaveTextContent(/^$/); }); it('should handle header-only CSV', async () => { @@ -112,4 +170,115 @@ describe('CsvPreview', () => { await expect.element(table).toBeInTheDocument(); await expect.element(page.getByText('Name')).toBeInTheDocument(); }); + + it('should handle headers with special characters', async () => { + const csv = 'first name,last.name,email-address\nJohn,Doe,john@example.com'; + render(CsvPreview, { text: csv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + for (const header of ['first name', 'last.name', 'email-address']) { + await expect.element(page.getByRole('cell', { name: header })).toBeInTheDocument(); + } + }); + + it('should handle rows with missing trailing columns', async () => { + const csv = 'a,b,c\n1,2\n3,4,5,6'; + render(CsvPreview, { text: csv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + const row1 = table.getByRole('row').nth(1); + await expect(row1.getByRole('cell').nth(2)).toHaveTextContent('2'); + await expect(row1.getByRole('cell').nth(3)).toHaveTextContent(''); + + const row2 = table.getByRole('row').nth(2); + await expect(row2.getByRole('cell').nth(3)).toHaveTextContent('5'); + }); + + it('should handle rows with empty quoted fields', async () => { + const csv = 'a,b,c\n1,"",3\nx,y,z'; + render(CsvPreview, { text: csv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + const row1 = page.getByRole('row').nth(1); + await expect(row1.getByRole('cell').nth(1)).toHaveTextContent('1'); + await expect(row1.getByRole('cell').nth(3)).toHaveTextContent('3'); + }); + + it('should handle CRLF line endings', async () => { + const csv = 'name,city\r\nAlice,Berlin\r\nBob,Munich\r\n'; + render(CsvPreview, { text: csv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + await expect.element(page.getByRole('cell', { name: 'Alice' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'Bob' })).toBeInTheDocument(); + }); + + it('should render single row CSV correctly', async () => { + const csv = 'header\nvalue'; + render(CsvPreview, { text: csv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + await expect.element(page.getByRole('cell', { name: 'header' })).toBeInTheDocument(); + await expect.element(page.getByRole('cell', { name: 'value' })).toBeInTheDocument(); + }); + + it('should not show truncation message when rows fit within limit', async () => { + const rows = ['h1,h2']; + for (let i = 0; i < 10; i++) { + rows.push(`val${i}a,val${i}b`); + } + const csv = rows.join('\n'); + render(CsvPreview, { text: csv }); + + const truncationMsg = page.getByText(/Showing/i); + expect(await truncationMsg.all()).toHaveLength(0); + }); + + it('should show truncation message when rows exceed MAX_ROWS', async () => { + const rows = ['h1,h2']; + for (let i = 0; i < 260; i++) { + rows.push(`val${i}a,val${i}b`); + } + const csv = rows.join('\n'); + render(CsvPreview, { text: csv }); + + await expect.element(page.getByText(/Showing/i)).toBeInTheDocument(); + }); + + it('should show truncation message for TSV with many rows', async () => { + const rows = ['h1\th2']; + for (let i = 0; i < 260; i++) { + rows.push(`val${i}a\tval${i}b`); + } + const tsv = rows.join('\n'); + render(CsvPreview, { text: tsv }); + + await expect.element(page.getByText(/Showing/i)).toBeInTheDocument(); + }); + + it('should render exactly MAX_ROWS rows when truncated', async () => { + const rows = ['h1']; + for (let i = 0; i < 300; i++) { + rows.push(`val${i}`); + } + const csv = rows.join('\n'); + const { container } = render(CsvPreview, { text: csv }); + + const table = page.getByRole('table', { name: 'CSV preview' }); + await expect.element(table).toBeInTheDocument(); + + const tables = await container.getElementsByTagName('table'); + expect(tables.length).toBe(1); + expect(tables[0].rows.length).toBe(251); + }); }); diff --git a/src/lib/components/storage/modals/preview/FallbackPreview.svelte b/src/lib/components/storage/modals/preview/FallbackPreview.svelte index fc483c28..a89d226e 100644 --- a/src/lib/components/storage/modals/preview/FallbackPreview.svelte +++ b/src/lib/components/storage/modals/preview/FallbackPreview.svelte @@ -10,9 +10,20 @@ isBinary?: boolean; /** When true, the image preview was skipped because the file is too large. */ imageTooLarge?: boolean; + /** When true, the file is a 7zip archive, which is not yet supported. */ + isSevenZip?: boolean; + /** When true, the file is a RAR archive, which is not supported. */ + isRar?: boolean; } - let { contentType, onDownload, isBinary = false, imageTooLarge = false }: Props = $props(); + let { + contentType, + onDownload, + isBinary = false, + imageTooLarge = false, + isSevenZip = false, + isRar = false + }: Props = $props();
@@ -28,9 +39,13 @@

{imageTooLarge ? m.storage_preview_image_too_large_desc() - : isBinary - ? m.storage_preview_binary_desc() - : m.storage_preview_unsupported_desc()} + : isSevenZip + ? m.storage_preview_7zip_unsupported_desc() + : isRar + ? m.storage_preview_rar_unsupported_desc() + : isBinary + ? m.storage_preview_binary_desc() + : m.storage_preview_unsupported_desc()}

{#if contentType}

{contentType}

diff --git a/src/lib/components/storage/modals/preview/ParquetMetadata.svelte b/src/lib/components/storage/modals/preview/ParquetMetadata.svelte new file mode 100644 index 00000000..38358f31 --- /dev/null +++ b/src/lib/components/storage/modals/preview/ParquetMetadata.svelte @@ -0,0 +1,337 @@ + + +
+ +
+

+

+
+
+
{m.storage_preview_parquet_file_size()}
+
{formatFileSize(totalSize)}
+
+
+
{m.storage_preview_parquet_total_rows()}
+
+ {totalRows.toLocaleString(getLocale())} +
+
+
+
{m.storage_preview_parquet_total_columns()}
+
{headers.length}
+
+
+
{m.storage_preview_parquet_version()}
+
v{metadata.version}
+
+ {#if metadata.createdBy} +
+
{m.storage_preview_parquet_created_by()}
+
{metadata.createdBy}
+
+ {/if} +
+
+ + +
+

+

+
+ + + + + + + + + + + + + + + + + + + + + {#each columnTypes as col, i (col.name)} + + + + + + + + + {/each} + +
#{m.storage_preview_parquet_column()}{m.storage_preview_parquet_type()}{m.storage_preview_parquet_compression()}{m.storage_preview_parquet_size()}{m.storage_preview_parquet_statistics()}
{i + 1} + {col.name} + + {col.type} + + {col.codec} + + {#if col.uncompressedSize > 0 || col.compressedSize > 0} +
+
+ + + {formatFileSize(col.compressedSize)} + + {#if totalUncompressed > 0} + + ({Math.round((col.uncompressedSize / totalUncompressed) * 100)}%) + + {/if} +
+
+
+
+
+ {:else} + — + {/if} +
+ {#if col.stats.min !== null || col.stats.max !== null || col.stats.nullCount !== null || col.stats.distinctCount !== null} +
+ {#if col.stats.min !== null} +
+ {m.storage_preview_parquet_min()} +
+
+ {col.stats.min} +
+ {/if} + {#if col.stats.max !== null} +
+ {m.storage_preview_parquet_max()} +
+
+ {col.stats.max} +
+ {/if} + {#if col.stats.nullCount !== null} +
+ {m.storage_preview_parquet_null_count()} +
+
+ {col.stats.nullCount.toLocaleString(getLocale())} +
+ {/if} + {#if col.stats.distinctCount !== null} +
+ {m.storage_preview_parquet_distinct_count()} +
+
+ {col.stats.distinctCount.toLocaleString(getLocale())} +
+ {/if} +
+ {:else} + {m.storage_preview_parquet_not_stored()} + {/if} +
+
+
+ + +
+

+

+
+
+
{m.storage_preview_parquet_row_groups()}
+
{metadata.rowGroups}
+
+
+
{m.storage_preview_parquet_compression()}
+
+ {#if metadata.compressionCodecs.length === 0} + {m.storage_preview_parquet_none()} + {:else} + {#each metadata.compressionCodecs as codec (codec)} + {codec} + {/each} + {#if metadata.compressionUniform && metadata.compressionCodecs.length === 1} + ({m.storage_preview_parquet_uniform()}) + {/if} + {/if} +
+
+
+
{m.storage_preview_parquet_offset_index()}
+
+ + {metadata.hasOffsetIndex + ? m.storage_preview_parquet_available() + : m.storage_preview_parquet_missing()} + +
+
+
+
{m.storage_preview_parquet_column_index()}
+
+ + {metadata.hasColumnIndex + ? m.storage_preview_parquet_available() + : m.storage_preview_parquet_missing()} + +
+
+
+
+ + + {#if metadata.arrowSchema} +
+

+

+
+ + {m.storage_preview_parquet_arrow_schema_present({ size: metadata.arrowSchema.length })} + + +
+ {#if showArrowSchema} +
{metadata.arrowSchema}
+ {/if} +
+ {/if} + + + {#if metadata.rowGroups > 0} +
+

+

+
+
+
{m.storage_preview_parquet_row_groups()}
+
{metadata.rowGroups}
+
+
+
+ {/if} +
diff --git a/src/lib/components/storage/modals/preview/ParquetMetadata.svelte.spec.ts b/src/lib/components/storage/modals/preview/ParquetMetadata.svelte.spec.ts new file mode 100644 index 00000000..5e0d9bfc --- /dev/null +++ b/src/lib/components/storage/modals/preview/ParquetMetadata.svelte.spec.ts @@ -0,0 +1,202 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import ParquetMetadata from './ParquetMetadata.svelte'; + +describe('ParquetMetadata', () => { + const defaultColumnTypes = [ + { + name: 'id', + type: 'int64', + codec: 'SNAPPY', + compressedSize: 100, + uncompressedSize: 500, + stats: { nullCount: 0, distinctCount: 100, min: '1', max: '100' } + }, + { + name: 'name', + type: 'string', + codec: 'SNAPPY', + compressedSize: 800, + uncompressedSize: 2000, + stats: { nullCount: 0, distinctCount: null, min: 'Alice', max: 'Bob' } + }, + { + name: 'score', + type: 'double', + codec: 'GZIP', + compressedSize: 50, + uncompressedSize: 300, + stats: { nullCount: null, distinctCount: null, min: null, max: null } + } + ]; + + const defaultMetadata = { + rowGroups: 3, + compressionCodecs: ['SNAPPY', 'GZIP'], + compressionUniform: false, + hasOffsetIndex: true, + hasColumnIndex: false, + createdBy: 'pyarrow', + version: 2, + arrowSchema: null + }; + + it('should render file overview', async () => { + render(ParquetMetadata, { + headers: ['id', 'name', 'score'], + columnTypes: defaultColumnTypes, + totalRows: 1000, + totalSize: 50000, + metadata: defaultMetadata + }); + + await expect.element(page.getByText('Overview')).toBeInTheDocument(); + await expect.element(page.getByText('File size')).toBeInTheDocument(); + await expect.element(page.getByText('Total rows')).toBeInTheDocument(); + await expect.element(page.getByText('1,000')).toBeInTheDocument(); + }); + + it('should render schema table with column types, compression, sizes and stats', async () => { + render(ParquetMetadata, { + headers: ['id', 'name', 'score'], + columnTypes: defaultColumnTypes, + totalRows: 1000, + totalSize: 50000, + metadata: defaultMetadata + }); + + await expect.element(page.getByText('Schema')).toBeInTheDocument(); + await expect.element(page.getByText('id')).toBeInTheDocument(); + await expect.element(page.getByText('name')).toBeInTheDocument(); + await expect.element(page.getByText('score')).toBeInTheDocument(); + await expect.element(page.getByText('int64')).toBeInTheDocument(); + await expect.element(page.getByText('string')).toBeInTheDocument(); + await expect.element(page.getByText('double')).toBeInTheDocument(); + + // Per-column compression + await expect.element(page.getByText('SNAPPY').first()).toBeInTheDocument(); + await expect.element(page.getByText('GZIP').first()).toBeInTheDocument(); + + // Stats labels + await expect.element(page.getByText('Min').first()).toBeInTheDocument(); + await expect.element(page.getByText('Max').first()).toBeInTheDocument(); + await expect.element(page.getByText('Null count').first()).toBeInTheDocument(); + await expect.element(page.getByText('Distinct count').first()).toBeInTheDocument(); + // Stats values for id column + await expect.element(page.getByText('1').first()).toBeInTheDocument(); + await expect.element(page.getByText('100').first()).toBeInTheDocument(); + + // Not stored for score column (no stats) + await expect.element(page.getByText('Not stored')).toBeInTheDocument(); + }); + + it('should render compression and indexes section', async () => { + render(ParquetMetadata, { + headers: ['id'], + columnTypes: [defaultColumnTypes[0]], + totalRows: 100, + totalSize: 2000, + metadata: defaultMetadata + }); + + await expect.element(page.getByText('Compression & Indexes').first()).toBeInTheDocument(); + await expect.element(page.getByText('SNAPPY').first()).toBeInTheDocument(); + await expect.element(page.getByText('GZIP')).toBeInTheDocument(); + await expect.element(page.getByText('Available')).toBeInTheDocument(); + await expect.element(page.getByText('Missing')).toBeInTheDocument(); + }); + + it('should show uniform label when compression is uniform', async () => { + render(ParquetMetadata, { + headers: ['id'], + columnTypes: [ + { + ...defaultColumnTypes[0], + codec: 'SNAPPY' + } + ], + totalRows: 100, + totalSize: 2000, + metadata: { ...defaultMetadata, compressionCodecs: ['SNAPPY'], compressionUniform: true } + }); + + await expect.element(page.getByText('uniform')).toBeInTheDocument(); + }); + + it('should render row groups section when rowGroups > 0', async () => { + render(ParquetMetadata, { + headers: ['id'], + columnTypes: [defaultColumnTypes[0]], + totalRows: 100, + totalSize: 2000, + metadata: defaultMetadata + }); + + await expect.element(page.getByText('Row groups').first()).toBeInTheDocument(); + }); + + it('should render created by when present', async () => { + render(ParquetMetadata, { + headers: ['id'], + columnTypes: [defaultColumnTypes[0]], + totalRows: 100, + totalSize: 2000, + metadata: defaultMetadata + }); + + await expect.element(page.getByText('pyarrow')).toBeInTheDocument(); + }); + + it('should show Arrow schema section when present', async () => { + render(ParquetMetadata, { + headers: ['id'], + columnTypes: [defaultColumnTypes[0]], + totalRows: 100, + totalSize: 2000, + metadata: { ...defaultMetadata, arrowSchema: 'id, name, score' } + }); + + await expect.element(page.getByText('Arrow schema').first()).toBeInTheDocument(); + + // Click "Show" to reveal the arrow schema content + const showBtn = page.getByRole('button', { name: 'Show' }); + await expect.element(showBtn).toBeInTheDocument(); + await showBtn.click(); + + await expect.element(page.getByText('id, name, score')).toBeInTheDocument(); + }); + + it('should handle empty column types', async () => { + render(ParquetMetadata, { + headers: [], + columnTypes: [], + totalRows: 0, + totalSize: 0, + metadata: { ...defaultMetadata, rowGroups: 0, arrowSchema: null } + }); + + await expect.element(page.getByText('Overview')).toBeInTheDocument(); + await expect.element(page.getByText('0 B')).toBeInTheDocument(); + }); + + it('should show None for empty compression codecs', async () => { + render(ParquetMetadata, { + headers: ['id'], + columnTypes: [ + { + ...defaultColumnTypes[0], + codec: 'SNAPPY', + compressedSize: 0, + uncompressedSize: 0, + stats: { nullCount: null, distinctCount: null, min: null, max: null } + } + ], + totalRows: 100, + totalSize: 2000, + metadata: { ...defaultMetadata, compressionCodecs: [] } + }); + + await expect.element(page.getByText('None')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/components/storage/modals/preview/ParquetPreview.svelte b/src/lib/components/storage/modals/preview/ParquetPreview.svelte new file mode 100644 index 00000000..68aee3b3 --- /dev/null +++ b/src/lib/components/storage/modals/preview/ParquetPreview.svelte @@ -0,0 +1,320 @@ + + +{#if isSimpleMode} +
+ {#if headers.length === 0} +

{m.storage_bucket_empty()}

+ {:else} +
+ + + + + {#each headers as header (header)} + + {/each} + + + + {#each initialRows as row, i (i)} + + + + {#each headers as _h, j (j)} + {#if row[j] !== undefined} + + {:else} + + {/if} + {/each} + + {/each} + +
{header}
{(i + 1).toLocaleString(getLocale())} + {row[j] !== null ? String(row[j]) : ''} + +
+
+
+ {#if isTruncated} +

+ {m.storage_preview_infinite_scroll_disabled()} +

+ {/if} + {/if} +
+{:else} +
+ {#if headers.length === 0} +

{m.storage_bucket_empty()}

+ {:else} + +
(scrollTop = e.currentTarget.scrollTop)} + > + + + + + {#each headers as header, j (header)} + + {/each} + + + + {#if paddingTop > 0} + + + + {/if} + {#each visibleRows as row (row.index)} + + + {#if row.data !== null && row.data !== undefined} + + {#each headers as _h, j (j)} + {#if row.data[j] !== undefined} + + {:else} + + {/if} + {/each} + {:else} + + {#each headers as _h, j (j)} + + {/each} + {/if} + + {/each} + {#if paddingBottom > 0} + + + + {/if} + +
0 ? `width: ${columnWidths[j]}px` : ''}>{header}
{(row.index + 1).toLocaleString(getLocale())} + {row.data[j] !== null ? String(row.data[j]) : ''} + +
+
+
+
+
+ {#if isTruncated} +

+ {m.storage_preview_parquet_rows + ? m.storage_preview_parquet_rows({ + count: formattedLoadedRowsCount, + total: formattedTotalRows + }) + : `Showing ${formattedLoadedRowsCount} of ${formattedTotalRows} rows. Scroll to load more.`} +

+ {/if} + {/if} +
+{/if} diff --git a/src/lib/components/storage/modals/preview/ParquetPreview.svelte.spec.ts b/src/lib/components/storage/modals/preview/ParquetPreview.svelte.spec.ts new file mode 100644 index 00000000..7f8622f2 --- /dev/null +++ b/src/lib/components/storage/modals/preview/ParquetPreview.svelte.spec.ts @@ -0,0 +1,191 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import ParquetPreview from './ParquetPreview.svelte'; + +describe('ParquetPreview', () => { + const defaultHeaders = ['id', 'name', 'email']; + const defaultRows = [ + ['1', 'Alice', 'alice@example.com'], + ['2', 'Bob', 'bob@example.com'], + ['3', 'Charlie', 'charlie@example.com'] + ]; + + it('should show empty state when headers array is empty', async () => { + render(ParquetPreview, { headers: [], initialRows: [] }); + + await expect.element(page.getByText('This bucket is empty')).toBeInTheDocument(); + }); + + it('should render a table with headers', async () => { + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: defaultRows + }); + + const table = page.getByRole('table', { name: 'Parquet preview' }); + await expect.element(table).toBeInTheDocument(); + + for (const header of defaultHeaders) { + await expect.element(page.getByText(header)).toBeInTheDocument(); + } + }); + + it('should render initial rows in the table', async () => { + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: defaultRows, + totalRows: 3 + }); + + await expect + .element(page.getByRole('cell', { name: 'Alice', exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByRole('cell', { name: 'bob@example.com', exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByRole('cell', { name: 'Charlie', exact: true })) + .toBeInTheDocument(); + }); + + it('should show row count message when totalRows exceeds loaded rows', async () => { + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: defaultRows, + totalRows: 1000 + }); + + // With default CHUNK_SIZE=250, loadedRowsCount = 250 (one chunk) + // totalRows = 1000, so isTruncated = true + await expect.element(page.getByText(/rows/)).toBeInTheDocument(); + }); + + it('should not show row count message when totalRows equals loaded rows', async () => { + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: defaultRows, + totalRows: 3 + }); + + // Only 3 rows loaded, totalRows = 3 — not truncated + const rowsMessages = page.getByText(/rows/i); + // The only text with "rows" should be the row count message. + // When not truncated, no such message appears. + expect(await rowsMessages.all()).toHaveLength(0); + }); + + it('should not show row count when totalRows is 0', async () => { + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: defaultRows, + totalRows: 0 + }); + + // 0 total rows, no truncation message + await expect.element(page.getByText(/Showing/i)).not.toBeInTheDocument(); + }); + + it('should render cell content using String() so null becomes empty string', async () => { + const rowsWithNull = [['1', 'Alice', null]]; + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: rowsWithNull, + totalRows: 1 + }); + + // Alice should be visible + await expect.element(page.getByText('Alice')).toBeInTheDocument(); + // The null cell should render as empty (not "null" string) + await expect.element(page.getByText(/null/)).not.toBeInTheDocument(); + }); + + it('should call fetchRows when scrolling to unloaded chunk', async () => { + const fetchRows = vi.fn().mockResolvedValue([ + ['4', 'Diana', 'diana@example.com'], + ['5', 'Eve', 'eve@example.com'] + ]); + + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: defaultRows, + totalRows: 500, + fetchRows + }); + + // The effect triggers fetchRows for chunks visible within the viewport + + // Wait for initial render and effect cycle + await vi.waitFor( + () => { + expect(fetchRows).toHaveBeenCalled(); + }, + { timeout: 2000 } + ); + }); + + it('should render skeleton rows while chunk is loading', async () => { + // Create a fetchRows that never resolves to simulate loading state + const fetchRows = vi.fn().mockReturnValue(new Promise(() => {})); + + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: defaultRows, + totalRows: 500, + fetchRows + }); + + // The loading skeleton is an animated pulse div inside the unloaded rows + // This is tricky to assert directly, so we verify fetchRows was called + // and no crash occurs + await vi.waitFor( + () => { + expect(fetchRows).toHaveBeenCalled(); + }, + { timeout: 2000 } + ); + }); + + it('should handle a single large initial data page', async () => { + const manyRows = Array.from({ length: 250 }, (_, i) => [ + String(i + 1), + `User-${i + 1}`, + `user${i + 1}@example.com` + ]); + + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: manyRows, + totalRows: 1000 + }); + + await expect.element(page.getByText('User-250')).toBeInTheDocument(); + }); + + it('should display all headers including those with special characters', async () => { + const specialHeaders = ['first name', 'last.name', 'email-address']; + render(ParquetPreview, { + headers: specialHeaders, + initialRows: [['John', 'Doe', 'john@example.com']] + }); + + for (const header of specialHeaders) { + await expect.element(page.getByText(header)).toBeInTheDocument(); + } + }); + + it('should handle empty rows array', async () => { + render(ParquetPreview, { + headers: defaultHeaders, + initialRows: [], + totalRows: 0 + }); + + const table = page.getByRole('table', { name: 'Parquet preview' }); + await expect.element(table).toBeInTheDocument(); + // Headers still render even when no rows + for (const header of defaultHeaders) { + await expect.element(page.getByText(header)).toBeInTheDocument(); + } + }); +}); diff --git a/src/lib/components/storage/modals/preview/TextPreview.svelte b/src/lib/components/storage/modals/preview/TextPreview.svelte index 6c4bb607..86cf67e6 100644 --- a/src/lib/components/storage/modals/preview/TextPreview.svelte +++ b/src/lib/components/storage/modals/preview/TextPreview.svelte @@ -1,4 +1,6 @@ -
{formatted}
+
+ +
{formatted}
+
diff --git a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte b/src/lib/components/storage/modals/shared/ConflictEntry.svelte similarity index 63% rename from src/lib/components/storage/modals/upload/UploadConflictEntry.svelte rename to src/lib/components/storage/modals/shared/ConflictEntry.svelte index 68fc270a..a6504a63 100644 --- a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte +++ b/src/lib/components/storage/modals/shared/ConflictEntry.svelte @@ -2,10 +2,12 @@ import IconCheck from 'virtual:icons/material-symbols/check'; import IconCheckCircle from 'virtual:icons/material-symbols/check-circle'; import * as m from '$lib/paraglide/messages.js'; - import type { FileEntry, Resolution } from './types.js'; + import { ConflictRenameNameSchema } from '$lib/storage/schemas.js'; + import { objectNameError } from '$lib/storage/name-validation.js'; + import type { ConflictEntry, Resolution } from './conflict-types.js'; interface Props { - entry: FileEntry; + entry: ConflictEntry; onSetResolution: (resolution: Resolution) => void; onSetCustomName: (name: string) => void; onRenameButtonClick: () => void; @@ -17,31 +19,68 @@ const uid = $props.id(); - let nameOnly = $derived(entry.targetKey.split('/').at(-1) ?? entry.file.name); - let badRename = $derived( + let sameName = $derived( entry.resolution === 'rename' && - (entry.customName.trim() === '' || entry.customName.trim() === entry.file.name) + (entry.customName.trim() === '' || entry.customName.trim() === entry.originalName) ); + // A transfer knows its destination folder, so the full key length is checked too. + let nameError = $derived.by(() => { + if (entry.resolution !== 'rename' || sameName) return null; + const name = entry.customName.trim(); + if (entry.destPrefix !== undefined) { + return objectNameError(name, { + prefix: entry.destPrefix, + folder: entry.kind === 'folder', + singleSegment: true + }); + } + return ConflictRenameNameSchema.safeParse(name).success + ? null + : m.storage_name_invalid_single(); + }); + let badRename = $derived(sameName || nameError !== null); + const folder = $derived(entry.kind === 'folder');
  • - “{nameOnly}” + “{entry.originalName}”

    - + {#if folder} + + {:else} + + {/if} + {#if entry.allowKeepBoth} + + {/if} + {#if hasFileConflicts} + + {/if} + {#if hasFolderConflicts} + + {/if} +
    + {#if hasFolderConflicts} +

    {m.storage_transfer_merge_note()}

    + {/if} + {/if} + +
      + {#each conflictEntries as entry (entry.id)} + setResolution(entry.id, res)} + onSetCustomName={(name) => setCustomName(entry.id, name)} + onRenameButtonClick={() => handleRenameButtonClick(entry.id)} + onCheckRename={() => doCheckRename(entry.id)} + /> + {/each} +
    + + {#if refused.length > 0} + {#if conflictEntries.length > 0} +

    + {m.storage_transfer_refused_title()} +

    + {/if} +
      + {#each refused as item, index (index)} +
    • + “{item.name}” + {item.reason} +
    • + {/each} +
    + {/if} + +
    + {#if closeOnly} + + {:else} + + + {/if} +
    +
  • + diff --git a/src/lib/components/storage/modals/shared/ConflictResolutionDialog.svelte.spec.ts b/src/lib/components/storage/modals/shared/ConflictResolutionDialog.svelte.spec.ts new file mode 100644 index 00000000..1e64a786 --- /dev/null +++ b/src/lib/components/storage/modals/shared/ConflictResolutionDialog.svelte.spec.ts @@ -0,0 +1,77 @@ +import { page, userEvent } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import ConflictResolutionDialog from './ConflictResolutionDialog.svelte'; +import type { ConflictEntry } from './conflict-types.js'; + +const entry: ConflictEntry = { + id: 'src/a.txt', + originalName: 'a.txt', + conflict: true, + resolution: null, + customName: 'a (1).txt', + renameState: 'idle', + sourceKey: 'src/a.txt', + kind: 'file', + allowKeepBoth: true, + destPrefix: 'dest/' +}; + +function renderDialog(onCheckRename: (entry: ConflictEntry) => Promise) { + return render(ConflictResolutionDialog, { + open: true, + entries: [entry], + confirmLabel: 'Paste', + onCheckRename, + onConfirm: vi.fn(), + onCancel: vi.fn() + }); +} + +async function confirmName(name: string) { + await userEvent.fill(page.getByLabelText('New file name'), name); + await page.getByRole('button', { name: 'Confirm name' }).click(); +} + +describe('ConflictResolutionDialog', () => { + it('enables the confirm button once the new name is reported free', async () => { + const onCheckRename = vi.fn(async () => true); + renderDialog(onCheckRename); + + await page.getByRole('button', { name: 'Rename', exact: true }).click(); + await confirmName('b.txt'); + + expect(onCheckRename).toHaveBeenCalledWith(expect.objectContaining({ customName: 'b.txt' })); + await expect.element(page.getByRole('button', { name: 'Paste' })).toBeEnabled(); + }); + + it('reports a taken name and keeps the confirm button disabled', async () => { + renderDialog(async () => false); + + await page.getByRole('button', { name: 'Rename', exact: true }).click(); + await confirmName('b.txt'); + + await expect + .element(page.getByText('This name already exists here. Please choose a different name.')) + .toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Paste' })).toBeDisabled(); + }); + + it('ignores a check result that arrives after the user changed the choice', async () => { + let answer!: (available: boolean) => void; + renderDialog(() => new Promise((resolve) => (answer = resolve))); + + await page.getByRole('button', { name: 'Rename', exact: true }).click(); + await confirmName('b.txt'); + await page.getByRole('button', { name: 'Skip', exact: true }).click(); + await page.getByRole('button', { name: 'Rename', exact: true }).click(); + await userEvent.fill(page.getByLabelText('New file name'), 'taken.txt'); + + answer(true); + // Let the stale result settle before asserting nothing changed. + await new Promise((resolve) => setTimeout(resolve, 0)); + + await expect.element(page.getByLabelText('New file name')).toHaveValue('taken.txt'); + await expect.element(page.getByRole('button', { name: 'Paste' })).toBeDisabled(); + }); +}); diff --git a/src/lib/components/storage/modals/shared/conflict-types.ts b/src/lib/components/storage/modals/shared/conflict-types.ts new file mode 100644 index 00000000..50ca7962 --- /dev/null +++ b/src/lib/components/storage/modals/shared/conflict-types.ts @@ -0,0 +1,27 @@ +/** `merge` and `keepBoth` are offered for transfers only. */ +export type Resolution = 'replace' | 'skip' | 'rename' | 'merge' | 'keepBoth'; + +export type RenameState = 'idle' | 'editing' | 'checking' | 'ok' | 'conflict'; + +export interface ConflictEntry { + id: string; + originalName: string; + conflict: boolean; + resolution: Resolution | null; + customName: string; + renameState: RenameState; + /** Source S3 key (set for paste/move operations, undefined for uploads). */ + sourceKey?: string; + /** Transfers only: a folder offers Merge instead of Replace. */ + kind?: 'file' | 'folder'; + /** Transfers only: offer Keep both. */ + allowKeepBoth?: boolean; + /** Transfers only: the folder a renamed item lands in, so the full key length can be checked. */ + destPrefix?: string; +} + +/** An item the server refused at plan time, shown with its reason. */ +export interface RefusedEntry { + name: string; + reason: string; +} diff --git a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte.spec.ts b/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte.spec.ts index d5fffc04..b58e3564 100644 --- a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte.spec.ts +++ b/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte.spec.ts @@ -1,23 +1,18 @@ -import { page } from 'vitest/browser'; import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { faker } from '@faker-js/faker'; -import UploadConflictEntry from './UploadConflictEntry.svelte'; -import type { FileEntry } from './types.js'; +import ConflictEntry from '../shared/ConflictEntry.svelte'; +import type { ConflictEntry as ConflictEntryType } from '../shared/conflict-types.js'; -function makeEntry(overrides: Partial = {}): FileEntry { - const name = overrides.file?.name ?? faker.system.fileName(); +function makeEntry(overrides: Partial = {}): ConflictEntryType { + const name = overrides.originalName ?? faker.system.fileName(); return { id: faker.string.uuid(), - file: new File(['content'], name), - displayPath: name, - targetKey: `path/to/${name}`, + originalName: name, conflict: true, resolution: null, customName: name, renameState: 'idle', - status: 'pending', - progress: 0, ...overrides }; } @@ -29,257 +24,208 @@ const defaultCallbacks = { onCheckRename: vi.fn() }; -describe('UploadConflictEntry', () => { +describe('ConflictEntry', () => { describe('rendering', () => { it('should show the file name in quotes', async () => { - const entry = makeEntry({ targetKey: 'folder/report.csv' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect.element(page.getByText(/\u201Creport.csv\u201D/)).toBeInTheDocument(); + const entry = makeEntry({ originalName: 'report.csv' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const listItem = screen.getByRole('group'); + await expect.element(listItem).toBeInTheDocument(); }); - it('should show a button group with Replace, Skip, Rename', async () => { + it('should default to Replace and Skip buttons in ghost variant', async () => { const entry = makeEntry(); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect.element(page.getByRole('group')).toBeInTheDocument(); - await expect.element(page.getByRole('button', { name: 'Replace' })).toBeInTheDocument(); - await expect.element(page.getByRole('button', { name: 'Skip' })).toBeInTheDocument(); - await expect.element(page.getByRole('button', { name: 'Rename' })).toBeInTheDocument(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const replaceBtn = screen.getByRole('button', { name: /Replace/ }); + const skipBtn = screen.getByRole('button', { name: /Skip/ }); + await expect.element(replaceBtn).toBeInTheDocument(); + await expect.element(skipBtn).toBeInTheDocument(); }); - it('should handle long filenames', async () => { - const longName = faker.string.alpha(200) + '.txt'; - const entry = makeEntry({ targetKey: `deep/nested/${longName}` }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); + it('should highlight Replace button with warning style when selected', async () => { + const entry = makeEntry({ resolution: 'replace' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const replaceBtn = screen.getByRole('button', { name: /Replace/ }); + await expect.element(replaceBtn).toHaveClass('btn-warning'); + }); - // eslint-disable-next-line security/detect-non-literal-regexp - await expect.element(page.getByText(new RegExp(longName.slice(0, 20)))).toBeInTheDocument(); + it('should highlight Skip button with neutral style when selected', async () => { + const entry = makeEntry({ resolution: 'skip' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const skipBtn = screen.getByRole('button', { name: /Skip/ }); + await expect.element(skipBtn).toHaveClass('btn-neutral'); }); - }); - describe('resolution buttons', () => { - it('should mark Replace as pressed when resolution is replace', async () => { + it('should apply aria-pressed on active button', async () => { const entry = makeEntry({ resolution: 'replace' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - const btn = page.getByRole('button', { name: 'Replace' }); - await expect.element(btn).toHaveAttribute('aria-pressed', 'true'); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const replaceBtn = screen.getByRole('button', { name: /Replace/ }); + await expect.element(replaceBtn).toHaveAttribute('aria-pressed', 'true'); }); - it('should mark Skip as pressed when resolution is skip', async () => { - const entry = makeEntry({ resolution: 'skip' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - const btn = page.getByRole('button', { name: 'Skip' }); - await expect.element(btn).toHaveAttribute('aria-pressed', 'true'); + it('should not set aria-pressed on inactive buttons', async () => { + const entry = makeEntry({ resolution: 'replace' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const skipBtn = screen.getByRole('button', { name: /Skip/ }); + await expect.element(skipBtn).toHaveAttribute('aria-pressed', 'false'); }); + }); - it('should call onSetResolution with replace when Replace is clicked', async () => { + describe('callbacks', () => { + it('should call onSetResolution when Replace button is clicked', async () => { const onSetResolution = vi.fn(); const entry = makeEntry(); - render(UploadConflictEntry, { entry, ...defaultCallbacks, onSetResolution }); - - await page.getByRole('button', { name: 'Replace' }).click(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks, onSetResolution }); + await screen.getByRole('button', { name: /Replace/ }).click(); expect(onSetResolution).toHaveBeenCalledWith('replace'); }); - it('should call onSetResolution with skip when Skip is clicked', async () => { + it('should call onSetResolution when Skip button is clicked', async () => { const onSetResolution = vi.fn(); - const entry = makeEntry(); - render(UploadConflictEntry, { entry, ...defaultCallbacks, onSetResolution }); - - await page.getByRole('button', { name: 'Skip' }).click(); + const entry = makeEntry({ resolution: 'replace' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks, onSetResolution }); + await screen.getByRole('button', { name: /Skip/ }).click(); expect(onSetResolution).toHaveBeenCalledWith('skip'); }); - it('should call onRenameButtonClick when Rename is clicked', async () => { + it('should call onRenameButtonClick when Rename button is clicked', async () => { const onRenameButtonClick = vi.fn(); const entry = makeEntry(); - render(UploadConflictEntry, { entry, ...defaultCallbacks, onRenameButtonClick }); - - await page.getByRole('button', { name: 'Rename' }).click(); - expect(onRenameButtonClick).toHaveBeenCalled(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks, onRenameButtonClick }); + await screen.getByRole('button', { name: /Rename/ }).click(); + expect(onRenameButtonClick).toHaveBeenCalledOnce(); }); - }); - - describe('rename state', () => { - it('should show loading spinner when renameState is checking', async () => { - const entry = makeEntry({ resolution: 'rename', renameState: 'checking' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - const btn = page.getByRole('button', { name: /Rename/ }); - await expect.element(btn).toBeDisabled(); + it('should show a text input in editing rename state', async () => { + const entry = makeEntry({ resolution: 'rename', renameState: 'editing' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const input = screen.getByPlaceholder('New file name'); + await expect.element(input).toBeInTheDocument(); }); - it('should show input field when renameState is editing', async () => { + it('should call onSetCustomName when text is entered', async () => { + const onSetCustomName = vi.fn(); const entry = makeEntry({ resolution: 'rename', renameState: 'editing' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect - .element(page.getByRole('textbox', { name: 'New file name' })) - .toBeInTheDocument(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks, onSetCustomName }); + const input = screen.getByPlaceholder('New file name'); + await input.fill('newname.csv'); + expect(onSetCustomName).toHaveBeenCalledWith('newname.csv'); }); - it('should show input field when renameState is conflict', async () => { - const entry = makeEntry({ resolution: 'rename', renameState: 'conflict' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); + it('should not call onRenameButtonClick before changing the value when Enter is pressed', async () => { + const onRenameButtonClick = vi.fn(); + const entry = makeEntry({ resolution: 'rename', renameState: 'editing' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks, onRenameButtonClick }); + // Should show the rename input + await expect.element(screen.getByPlaceholder('New file name')).toBeInTheDocument(); + }); - await expect - .element(page.getByRole('textbox', { name: 'New file name' })) - .toBeInTheDocument(); + it('should show a spinner during rename checking', async () => { + const entry = makeEntry({ resolution: 'rename', renameState: 'checking' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + await expect.element(screen.getByRole('button', { name: /Rename/ })).toBeInTheDocument(); + const renameBtn = screen.getByRole('button', { name: /Rename/ }); + await expect.element(renameBtn).toBeDisabled(); }); - it('should show error when renameState is conflict', async () => { - const file = new File(['x'], 'original.txt'); + it('should show a check icon when rename is confirmed', async () => { const entry = makeEntry({ - file, resolution: 'rename', - renameState: 'conflict', - customName: 'different-name.txt' + renameState: 'ok', + customName: 'newname.csv' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + await expect.element(screen.getByText('newname.csv')).toBeInTheDocument(); + }); - await expect - .element(page.getByRole('alert')) - .toHaveTextContent('This name already exists here.'); + it('should change label to "Confirm name" when in editing state', async () => { + const entry = makeEntry({ resolution: 'rename', renameState: 'editing' }); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const renameBtn = screen.getByRole('button', { name: /Confirm name/ }); + await expect.element(renameBtn).toBeInTheDocument(); }); - it('should show error when customName is empty (badRename)', async () => { + it('should show an error when new name matches the original', async () => { const entry = makeEntry({ resolution: 'rename', renameState: 'editing', - customName: ' ' + originalName: 'same.txt', + customName: 'same.txt' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect.element(page.getByRole('alert')).toBeInTheDocument(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + await expect + .element(screen.getByText('Please enter a different name, or select Replace or Skip.')) + .toBeInTheDocument(); }); - it('should show error when customName matches original file name', async () => { - const file = new File(['x'], 'original.txt'); + it('should show an error when the new name contains a slash', async () => { const entry = makeEntry({ - file, resolution: 'rename', renameState: 'editing', - customName: 'original.txt' + originalName: 'original.txt', + customName: 'sub/new.txt' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect.element(page.getByRole('alert')).toBeInTheDocument(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + await expect + .element(screen.getByText(/Enter a single name without "\/"/)) + .toBeInTheDocument(); + await expect.element(screen.getByRole('textbox')).toHaveClass('input-error'); + await expect.element(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true'); }); - it('should call onCheckRename when Enter is pressed in input', async () => { - const onCheckRename = vi.fn(); + it('should show an error when rename conflicts with existing file', async () => { const entry = makeEntry({ resolution: 'rename', - renameState: 'editing', - customName: 'new-name.txt' + renameState: 'conflict', + originalName: 'original.txt', + customName: 'newname.txt' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks, onCheckRename }); - - const input = page.getByRole('textbox', { name: 'New file name' }); - const inputEl = input.element() as HTMLInputElement; - inputEl.focus(); - inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - expect(onCheckRename).toHaveBeenCalled(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + await expect.element(screen.getByText(/This name already exists/)).toBeInTheDocument(); }); - it('should show "Confirm name" text when editing', async () => { + it('should call onRenameButtonClick when the Rename button shows "Confirm name"', async () => { + const onRenameButtonClick = vi.fn(); const entry = makeEntry({ resolution: 'rename', renameState: 'editing' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect.element(page.getByRole('button', { name: /Confirm name/ })).toBeInTheDocument(); - }); - - it('should show check icon and success button when renameState is ok', async () => { - const entry = makeEntry({ - resolution: 'rename', - renameState: 'ok', - customName: 'new-name.txt' - }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - // Should show the static display with the custom name (not the input) - await expect.element(page.getByText('new-name.txt')).toBeInTheDocument(); - // Button should have success styling and show "Rename" text (not "Confirm name") - const btn = page.getByRole('button', { name: /Rename/ }); - await expect.element(btn).toBeInTheDocument(); - await expect.element(btn).toHaveAttribute('aria-pressed', 'true'); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks, onRenameButtonClick }); + await screen.getByRole('button', { name: /Confirm name/ }).click(); + expect(onRenameButtonClick).toHaveBeenCalledOnce(); }); - it('should call onSetCustomName when input value changes', async () => { - const onSetCustomName = vi.fn(); + it('should show error state styling on the input when the same name is entered', async () => { const entry = makeEntry({ resolution: 'rename', renameState: 'editing', - customName: 'old.txt' + originalName: 'same.txt', + customName: 'same.txt' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks, onSetCustomName }); - - const input = page.getByRole('textbox', { name: 'New file name' }); - const inputEl = input.element() as HTMLInputElement; - inputEl.focus(); - inputEl.value = 'new-value.txt'; - inputEl.dispatchEvent(new Event('input', { bubbles: true })); - expect(onSetCustomName).toHaveBeenCalledWith('new-value.txt'); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const input = screen.getByPlaceholder('New file name'); + await expect.element(input).toHaveClass('input-error'); }); - it('should show "Confirm name" text when renameState is conflict', async () => { - const file = new File(['x'], 'original.txt'); + it('should show error state styling on the input when rename conflicts', async () => { const entry = makeEntry({ - file, resolution: 'rename', renameState: 'conflict', - customName: 'different.txt' - }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect.element(page.getByRole('button', { name: /Confirm name/ })).toBeInTheDocument(); - }); - - it('should not call onCheckRename when a non-Enter key is pressed', async () => { - const onCheckRename = vi.fn(); - const entry = makeEntry({ - resolution: 'rename', - renameState: 'editing', - customName: 'new-name.txt' + originalName: 'original.txt', + customName: 'newname.txt' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks, onCheckRename }); - - const input = page.getByRole('textbox', { name: 'New file name' }); - const inputEl = input.element() as HTMLInputElement; - inputEl.focus(); - inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); - expect(onCheckRename).not.toHaveBeenCalled(); - }); - }); - - describe('nameOnly derivation', () => { - it('should fall back to file.name when targetKey has no path separator', async () => { - const file = new File(['x'], 'fallback.txt'); - const entry = makeEntry({ file, targetKey: 'fallback.txt' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect.element(page.getByText(/\u201Cfallback.txt\u201D/)).toBeInTheDocument(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + const input = screen.getByPlaceholder('New file name'); + await expect.element(input).toHaveClass('input-error'); }); - it('should not show error when customName is valid and different', async () => { - const file = new File(['x'], 'original.txt'); + it('should show an input in conflict state with rename selected', async () => { const entry = makeEntry({ - file, resolution: 'rename', - renameState: 'editing', - customName: 'different.txt' + renameState: 'conflict', + originalName: 'original.txt', + customName: 'newname.txt' }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - // Input should be present and have no error styling - const input = page.getByRole('textbox', { name: 'New file name' }); - await expect.element(input).toBeInTheDocument(); - // The input should not have error class - const inputEl = input.element() as HTMLInputElement; - expect(inputEl.classList.contains('input-error')).toBe(false); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks }); + await expect.element(screen.getByPlaceholder('New file name')).toBeInTheDocument(); + await expect.element(screen.getByText(/This name already exists/)).toBeInTheDocument(); }); }); }); diff --git a/src/lib/components/storage/modals/upload/UploadModal.svelte b/src/lib/components/storage/modals/upload/UploadModal.svelte index baa67985..8277f9a7 100644 --- a/src/lib/components/storage/modals/upload/UploadModal.svelte +++ b/src/lib/components/storage/modals/upload/UploadModal.svelte @@ -1,28 +1,37 @@ + +{#if open} + + +{/if} diff --git a/src/lib/components/storage/shared/TimestampDisplay.svelte b/src/lib/components/storage/shared/TimestampDisplay.svelte index 72b3f835..894ac255 100644 --- a/src/lib/components/storage/shared/TimestampDisplay.svelte +++ b/src/lib/components/storage/shared/TimestampDisplay.svelte @@ -12,11 +12,18 @@ * When false (default), the label shows a short formatted date ("13 May 2026"). */ relative?: boolean; + /** When true, the tooltip shows the full timestamp. */ + showFullTimestamp?: boolean; /** DaisyUI tooltip direction. */ tooltip?: TooltipPosition; } - let { date, relative = false, tooltip = 'tooltip-top' }: Props = $props(); + let { + date, + relative = false, + tooltip = 'tooltip-top', + showFullTimestamp = false + }: Props = $props(); const d = $derived(typeof date === 'string' ? new Date(date) : date); @@ -45,6 +52,7 @@ const years = Math.floor(days / 365); return m.timestamp_years_ago({ count: years }); } + if (showFullTimestamp) return fullTimestamp; return new Intl.DateTimeFormat(getLocale(), { year: 'numeric', month: 'short', diff --git a/src/lib/components/storage/sidebar/BucketList.svelte b/src/lib/components/storage/sidebar/BucketList.svelte index 5a1de726..4239a5d1 100644 --- a/src/lib/components/storage/sidebar/BucketList.svelte +++ b/src/lib/components/storage/sidebar/BucketList.svelte @@ -1,18 +1,21 @@ @@ -111,15 +153,17 @@ {m.storage_context_menu_actions()} - +
    + +
  • @@ -136,153 +180,186 @@ border-base-300 bg-base-100 relative flex shrink-0 flex-col rounded-lg border " - style="width: {resize.width}px" + style="width: var(--storage-sidebar-width, {resize.width}px)" aria-label={m.storage_buckets_label()} > - - {#if storage.bookmarks.pinnedLocations.length > 0} -
    -
    - - {m.storage_pinned_label()} - -
    - + {#if !storage.connected || !storage.connectionHostname} +
    +
    - {/if} + {:else} + + {#if storage.bookmarks.pinnedLocations.length > 0} +
    +
    + + {m.storage_pinned_label()} + +
    + +
    + {/if} -
    - - {m.storage_buckets_label()} - - - -
    + > + {m.storage_buckets_label()} + + + +
    -
      - {#if storage.buckets.length === 0} -
    • - {m.storage_buckets_empty()} -
    • - {:else} - {#each storage.buckets as bucket (bucket)} -
    • - - -
    • - {/each} - {/if} -
    +
    + +
    + + +
    +
    + +
    +
    - -
    -
    + - -
    + - - - - - +
  • {/each} {/if} @@ -262,7 +338,7 @@
    {m.storage_connect_manage()} @@ -271,11 +347,3 @@ - - - (forgetOpen = false)} -/> diff --git a/src/lib/components/storage/sidebar/__tests__/BucketListWrapper.svelte b/src/lib/components/storage/sidebar/__tests__/BucketListWrapper.svelte index 622b879f..46bb3fdc 100644 --- a/src/lib/components/storage/sidebar/__tests__/BucketListWrapper.svelte +++ b/src/lib/components/storage/sidebar/__tests__/BucketListWrapper.svelte @@ -9,7 +9,10 @@ } let { state }: Props = $props(); - untrack(() => setStorageState(state)); + untrack(() => { + state.connectionHostname = 'storage.example.com'; + setStorageState(state); + }); diff --git a/src/lib/components/storage/sidebar/resizable-panel.svelte.ts b/src/lib/components/storage/sidebar/resizable-panel.svelte.ts index 80af2e0e..79aca024 100644 --- a/src/lib/components/storage/sidebar/resizable-panel.svelte.ts +++ b/src/lib/components/storage/sidebar/resizable-panel.svelte.ts @@ -36,6 +36,7 @@ export function createResizablePanel({ function saveWidth(w: number) { try { localStorage.setItem(storageKey, String(w)); + document.documentElement.style.setProperty('--storage-sidebar-width', w + 'px'); } catch { /* ignore storage errors */ } diff --git a/src/lib/components/trino/StatementResult.svelte b/src/lib/components/trino/StatementResult.svelte index 5b06f6d7..a8fc507f 100644 --- a/src/lib/components/trino/StatementResult.svelte +++ b/src/lib/components/trino/StatementResult.svelte @@ -158,12 +158,14 @@ {#if result.trinoQueryUrl || result.columns.length > 0}
    {#if result.trinoQueryUrl} + + {m.trino_view_in_trino()} = CAPS[category]) break; } } @@ -77,11 +79,13 @@ function scheduleSave(): void { * category. Moves it to the front of the LRU list and persists. */ export function recordUse(category: HistoryCategory, name: string): void { const history = loadHistory(); + const list = history[category]; const index = list.indexOf(name); if (index === 0) return; // already at the head, nothing to do if (index > 0) list.splice(index, 1); list.unshift(name); + if (list.length > CAPS[category]) list.length = CAPS[category]; scheduleSave(); } @@ -90,6 +94,7 @@ export function recordUse(category: HistoryCategory, name: string): void { * present. The caller uses this to bias `sortText`. */ export function rankOf(category: HistoryCategory, name: string): number | null { const history = loadHistory(); + const index = history[category].indexOf(name); return index < 0 ? null : index; } diff --git a/src/lib/editor/completion/cursor-context.ts b/src/lib/editor/completion/cursor-context.ts index 1a9b6669..49a3632e 100644 --- a/src/lib/editor/completion/cursor-context.ts +++ b/src/lib/editor/completion/cursor-context.ts @@ -65,6 +65,7 @@ function cursorScopeTokens(tokens: Token[], cursor: number): Token[] | null { } const { pos: end, closed } = parenGroupEndPosition(tokens, pos); + const openChar = tokens[pos].start; // Cursor is inside this body if it's past the opening `(` and either @@ -97,6 +98,7 @@ export function extractPrefixAtCursor( ): { prefixParts: string[]; wordAtCursor: string } { // Step 1: find the last token that starts before the cursor. let pos = tokens.length - 1; + while (pos >= 0 && tokens[pos].start >= cursorInStatement) pos--; if (pos < 0) return { prefixParts: [], wordAtCursor: '' }; @@ -104,6 +106,7 @@ export function extractPrefixAtCursor( // A) cursor is inside/at the end of an identifier → that's the partial word // B) cursor is immediately after a dot → no word yet, prefix continues // C) cursor is attached to something else (keyword, operator) → no prefix + const last = tokens[pos]; const lastEndExclusive = last.stop + 1; const cursorTouchesLast = lastEndExclusive >= cursorInStatement; @@ -124,6 +127,7 @@ export function extractPrefixAtCursor( // Step 3: walk backwards through (DOT IDENTIFIER)* pairs. const prefixParts: string[] = []; + while (pos >= 1 && tokens[pos].type === DOT && IDENTIFIER_TOKENS.has(tokens[pos - 1].type)) { prefixParts.unshift(unquoteIdentifier(tokens[pos - 1].text ?? '')); pos -= 2; @@ -179,7 +183,9 @@ export function extractAliasMap( // Lowercase for case-insensitive lookup. aliasMap.set(alias.table.toLowerCase(), alias); // Optional [AS] follows the name. + if (tokens[next]?.type === SqlBaseLexer.AS) next++; + if (tokens[next] && IDENTIFIER_TOKENS.has(tokens[next].type)) { const aliasName = unquoteIdentifier(tokens[next].text ?? ''); aliasMap.set(aliasName.toLowerCase(), alias); @@ -194,16 +200,19 @@ export function extractAliasMap( // the JSDoc for why the body is skipped). if (token.type === SqlBaseLexer.WITH) { let next = pos + 1; + while (next < tokens.length && IDENTIFIER_TOKENS.has(tokens[next].type)) { const cteName = unquoteIdentifier(tokens[next].text ?? ''); aliasMap.set(cteName.toLowerCase(), { table: cteName }); next++; next = skipOptionalParenGroup(tokens, next); // optional column list + if (tokens[next]?.type === SqlBaseLexer.AS) next++; next = skipOptionalParenGroup(tokens, next); // CTE body // Comma means another CTE follows; anything else ends the WITH list. + if (tokens[next]?.type !== COMMA) break; next++; } diff --git a/src/lib/editor/completion/grammar-analysis.ts b/src/lib/editor/completion/grammar-analysis.ts index 8df5de7b..6b3831b3 100644 --- a/src/lib/editor/completion/grammar-analysis.ts +++ b/src/lib/editor/completion/grammar-analysis.ts @@ -5,11 +5,11 @@ // injects phantom identifiers before extraneous keywords flagged by ANTLR. import { - ATNSimulator, + type ATNSimulator, BaseErrorListener, CharStream, CommonTokenStream, - Recognizer, + type Recognizer, Token } from 'antlr4ng'; import { CodeCompletionCore } from 'antlr4-c3'; diff --git a/src/lib/editor/format-json.test.ts b/src/lib/editor/format-json.test.ts new file mode 100644 index 00000000..59b2c853 --- /dev/null +++ b/src/lib/editor/format-json.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest'; +import { prettifyJson, repairTruncatedJson, stripIncompleteTail } from './format-json'; + +describe('prettifyJson', () => { + it('returns empty string for empty input', () => { + expect(prettifyJson('')).toBe(''); + }); + + it('formats valid JSON object with indentation', () => { + const result = prettifyJson('{"a":1,"b":2}'); + expect(result).toBe('{\n "a": 1,\n "b": 2\n}'); + }); + + it('formats valid JSON array with indentation', () => { + const result = prettifyJson('[1,2,3]'); + expect(result).toBe('[\n 1,\n 2,\n 3\n]'); + }); + + it('formats nested JSON with proper indentation', () => { + const result = prettifyJson('{"a":{"b":[1,2]}}'); + expect(result).toBe('{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}'); + }); + + it('is idempotent—already formatted JSON returns unchanged', () => { + const formatted = '{\n "a": 1\n}'; + expect(prettifyJson(formatted)).toBe(formatted); + }); + + it('handles primitive JSON values', () => { + expect(prettifyJson('true')).toBe('true'); + expect(prettifyJson('null')).toBe('null'); + expect(prettifyJson('42')).toBe('42'); + expect(prettifyJson('"hello"')).toBe('"hello"'); + }); + + it('repairs truncated JSON with unclosed object', () => { + const result = prettifyJson('{"a":1,'); + expect(result).toBe('{\n "a": 1\n}'); + }); + + it('repairs truncated JSON with unclosed nested object', () => { + const result = prettifyJson('{"a":{"b":1'); + expect(result).toBe('{\n "a": {\n "b": 1\n }\n}'); + }); + + it('repairs truncated JSON with unclosed string', () => { + const result = prettifyJson('{"a":"hello'); + expect(result).toBe('{\n "a": "hello"\n}'); + }); + + it('returns raw text for completely invalid JSON', () => { + const invalid = '{not valid json: }}}'; + expect(prettifyJson(invalid)).toBe(invalid); + }); + + it('strips incomplete trailing element and formats', () => { + const result = prettifyJson('{"a":1,"b":'); + expect(result).toBe('{\n "a": 1\n}'); + }); + + it('strips incomplete trailing array element and formats', () => { + const result = prettifyJson('[1,2,'); + expect(result).toBe('[\n 1,\n 2\n]'); + }); +}); + +describe('repairTruncatedJson', () => { + it('passes already-valid JSON through unchanged', () => { + expect(repairTruncatedJson('{"a":1}')).toBe('{"a":1}'); + }); + + it('passes already-valid JSON array through unchanged', () => { + expect(repairTruncatedJson('[1,2,3]')).toBe('[1,2,3]'); + }); + + it('closes an unclosed object', () => { + expect(repairTruncatedJson('{"a":1')).toBe('{"a":1}'); + }); + + it('closes an unclosed array', () => { + expect(repairTruncatedJson('[1,2,3')).toBe('[1,2,3]'); + }); + + it('closes nested unclosed structures', () => { + expect(repairTruncatedJson('{"a":{"b":[1,2')).toBe('{"a":{"b":[1,2]}}'); + }); + + it('closes an unclosed string', () => { + expect(repairTruncatedJson('{"a":"hello')).toBe('{"a":"hello"}'); + }); + + it('handles unclosed string after an escape character', () => { + expect(repairTruncatedJson('{"a":"hello\\')).toBe('{"a":"hello"}'); + }); + + it('removes trailing comma before closing brackets', () => { + expect(repairTruncatedJson('{"a":1,')).toBe('{"a":1}'); + }); + + it('handles empty input', () => { + expect(repairTruncatedJson('')).toBe(''); + }); + + it('handles unclosed object in empty object', () => { + expect(repairTruncatedJson('{')).toBe('{}'); + }); + + it('handles unclosed array in empty array', () => { + expect(repairTruncatedJson('[')).toBe('[]'); + }); + + it('does not close brackets inside strings', () => { + expect(repairTruncatedJson('{"a":"{b}"')).toBe('{"a":"{b}"}'); + }); + + it('ignores escaped quotes inside strings', () => { + expect(repairTruncatedJson('{"a":"he\\"llo')).toBe('{"a":"he\\"llo"}'); + }); +}); + +describe('stripIncompleteTail', () => { + it('passes valid JSON through unchanged', () => { + const valid = '{"a":1,"b":2}'; + expect(stripIncompleteTail(valid)).toBe(valid); + }); + + it('strips trailing comma with incomplete value', () => { + const result = stripIncompleteTail('{"a":1,"b":'); + expect(result).toBe('{"a":1}'); + }); + + it('strips incomplete array element', () => { + const result = stripIncompleteTail('[1,2,'); + expect(result).toBe('[1,2]'); + }); + + it('strips deeply nested incomplete value', () => { + const result = stripIncompleteTail('{"a":{"b":[1,2,'); + // stripIncompleteTail removes the trailing comma+value, repairTruncatedJson closes the brackets + expect(result).toBe('{"a":{"b":[1,2]}}'); + }); + + it('returns input unchanged for an unbalanced opening bracket at the end', () => { + const result = stripIncompleteTail('{"a":1,{'); + // The function cannot cleanly strip the incomplete `{` — this falls through to the raw-text fallback in prettifyJson + expect(result).toBe('{"a":1,{'); + }); + + it('returns input unchanged when no strip is possible', () => { + const input = 'completely broken{'; + expect(stripIncompleteTail(input)).toBe(input); + }); + + it('recursively strips when re-closing does not produce valid JSON', () => { + // stripIncompleteTail will strip the last comma part, then the recursive + // call on the result may need to strip more + const result = stripIncompleteTail('{"a":1,"b":2,'); + // First pass strips the trailing comma → {"a":1,"b":2}, re-closes → {"a":1,"b":2} + expect(result).toBe('{"a":1,"b":2}'); + }); + + it('strips incomplete value inside a string context correctly', () => { + const result = stripIncompleteTail('{"a":"hello","b":'); + expect(result).toBe('{"a":"hello"}'); + }); +}); + +describe('prettifyJson — complex incomplete JSON', () => { + it('formats deeply nested truncated JSON: nested objects and arrays', () => { + const result = prettifyJson('{"level1":{"level2":{"level3":[1,2,3],"level3b":{'); + expect(result).toBe( + '{\n "level1": {\n "level2": {\n "level3": [\n 1,\n 2,\n 3\n ],\n "level3b": {}\n }\n }\n}' + ); + }); + + it('repairs JSON truncated in the middle of a string value', () => { + const result = prettifyJson('{"message":"hello world'); + expect(result).toBe('{\n "message": "hello world"\n}'); + }); + + it('repairs JSON with escaped quotes in truncated string', () => { + const result = prettifyJson('{"text":"he said \\"hello'); + expect(result).toBe('{\n "text": "he said \\"hello"\n}'); + }); + + it('returns raw text when truncation leaves a key without a value', () => { + // repairTruncatedJson closes the string → `{"c"}` which isn't valid JSON + const result = prettifyJson('[{"a":1},{"b":2},{"c'); + expect(result).toBe('[{"a":1},{"b":2},{"c'); + }); + + it('repairs truncated JSON with trailing comma at end of object', () => { + const result = prettifyJson('{"a":1,"b":2,'); + expect(result).toBe('{\n "a": 1,\n "b": 2\n}'); + }); + + it('repairs truncated JSON with trailing comma at end of array', () => { + const result = prettifyJson('[1,2,3,'); + expect(result).toBe('[\n 1,\n 2,\n 3\n]'); + }); + + it('repairs single-element truncated array', () => { + const result = prettifyJson('[1,'); + expect(result).toBe('[\n 1\n]'); + }); + + it('returns an empty object for JSON truncated to just an opening brace', () => { + // repairTruncatedJson closes the brace → `{}` which is valid JSON + expect(prettifyJson('{')).toBe('{}'); + }); + + it('returns raw text for JSON that is completely malformed', () => { + expect(prettifyJson('{broken json!!!}')).toBe('{broken json!!!}'); + }); + + it('returns raw text when nested truncation produces key without value', () => { + const result = prettifyJson('{"items":[1,2,3,{"nested'); + expect(result).toBe('{"items":[1,2,3,{"nested'); + }); + + it('handles JSON truncated at a comma after a closing bracket', () => { + const result = prettifyJson('[{"a":1},'); + expect(result).toBe('[\n {\n "a": 1\n }\n]'); + }); + + it('formats valid JSON with all value types', () => { + const result = prettifyJson( + '{"str":"hello","num":42,"bool":true,"null":null,"arr":[1,2],"obj":{"k":"v"}}' + ); + expect(result).toBe( + '{\n "str": "hello",\n "num": 42,\n "bool": true,\n "null": null,\n "arr": [\n 1,\n 2\n ],\n "obj": {\n "k": "v"\n }\n}' + ); + }); + + it('returns raw text when JSON is truncated mid-key', () => { + // repairTruncatedJson closes the string → `{"onlykey"}` which isn't valid JSON + const result = prettifyJson('{"onlykey'); + expect(result).toBe('{"onlykey'); + }); +}); diff --git a/src/lib/editor/format-json.ts b/src/lib/editor/format-json.ts new file mode 100644 index 00000000..7d50adc9 --- /dev/null +++ b/src/lib/editor/format-json.ts @@ -0,0 +1,123 @@ +export function repairTruncatedJson(text: string): string { + let result = text; + let inString = false; + let escape = false; + const openBrackets: string[] = []; + + for (let i = 0; i < result.length; i++) { + const ch = result[i]; + if (escape) { + escape = false; + continue; + } + if (inString) { + if (ch === '\\') { + escape = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + } else if (ch === '{' || ch === '[') { + openBrackets.push(ch); + } else if (ch === '}') { + if (openBrackets.at(-1) === '{') openBrackets.pop(); + } else if (ch === ']') { + if (openBrackets.at(-1) === '[') openBrackets.pop(); + } + } + + if (inString) { + if (escape) result = result.slice(0, -1); + result += '"'; + } + + result = result.trimEnd(); + if (result.endsWith(',')) { + result = result.slice(0, -1).trimEnd(); + } + + for (let i = openBrackets.length - 1; i >= 0; i--) { + result += openBrackets[i] === '{' ? '}' : ']'; + } + + return result; +} + +export function stripIncompleteTail(json: string): string { + let depth = 0; + let inStr = false; + let esc = false; + let lastComma = -1; + + for (let i = json.length - 1; i >= 0; i--) { + const ch = json[i]; + if (esc) { + esc = false; + continue; + } + if (inStr) { + if (ch === '\\') { + esc = true; + } else if (ch === '"') { + inStr = false; + } + continue; + } + if (ch === '"') { + inStr = true; + } else if (ch === '}' || ch === ']') { + depth++; + } else if (ch === '{' || ch === '[') { + depth--; + if (depth < 0) return json.substring(0, i + 1); + } else if (ch === ',' && depth === 0) { + lastComma = i; + break; + } + } + + if (lastComma >= 0) { + const before = json.substring(0, lastComma).trimEnd(); + const reClosed = repairTruncatedJson(before); + try { + JSON.parse(reClosed); + return reClosed; + } catch { + const simpler = stripIncompleteTail(before); + if (simpler !== before) return simpler; + } + } + + return json; +} + +export function prettifyJson(text: string): string { + if (!text) return text; + + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + // not valid — try to repair truncated JSON + } + + const basic = repairTruncatedJson(text); + try { + return JSON.stringify(JSON.parse(basic), null, 2); + } catch { + // still invalid — strip trailing incomplete elements and retry + } + + const stripped = stripIncompleteTail(text); + if (stripped !== text) { + try { + return JSON.stringify(JSON.parse(stripped), null, 2); + } catch { + // give up + } + } + + return text; +} diff --git a/src/lib/editor/grammar/patch-generated.mjs b/src/lib/editor/grammar/patch-generated.mjs index 92abf90e..3b539ee6 100644 --- a/src/lib/editor/grammar/patch-generated.mjs +++ b/src/lib/editor/grammar/patch-generated.mjs @@ -4,13 +4,13 @@ // as of antlr-ng 1.0.10; we should file them eventually and drop each // substitution as it's fixed upstream. All substitutions are idempotent. // -// 1. ParseTreeListener imported as a value +// 1. Listener types imported as values // ---------------------------------------- // antlr4ng declares `ParseTreeListener` as `export interface` (see // node_modules/antlr4ng/dist/tree/ParseTreeListener.d.ts). antlr-ng doesn't // model which antlr4ng exports are types vs values, so it emits a plain // value import. That fails under `verbatimModuleSyntax` / strict ESM. -// Fix: rewrite as `import type { ParseTreeListener }`. +// Fix: use a type-only import for all listener types. // // 2. Unqualified `ParserRuleContext` in the `predicate` rule // -------------------------------------------------------- @@ -45,8 +45,7 @@ function patchListener() { const original = 'import { ErrorNode, ParseTreeListener, ParserRuleContext, TerminalNode } from "antlr4ng";'; const replacement = - 'import type { ParseTreeListener } from "antlr4ng";\n' + - 'import { ErrorNode, ParserRuleContext, TerminalNode } from "antlr4ng";'; + 'import type { ErrorNode, ParseTreeListener, ParserRuleContext, TerminalNode } from "antlr4ng";'; if (before.includes(replacement)) return; if (!before.includes(original)) { throw new Error(`patch-generated: could not find listener import in ${path}`); diff --git a/src/lib/editor/lexer-utils.test.ts b/src/lib/editor/lexer-utils.test.ts index 1100f54d..bb5403a4 100644 --- a/src/lib/editor/lexer-utils.test.ts +++ b/src/lib/editor/lexer-utils.test.ts @@ -9,8 +9,11 @@ describe('implicit token constants match the generated grammar', () => { const names = SqlBaseLexer.literalNames; it('DOT is "."', () => expect(names[DOT]).toBe("'.'")); + it('LPAREN is "("', () => expect(names[LPAREN]).toBe("'('")); + it('RPAREN is ")"', () => expect(names[RPAREN]).toBe("')'")); + it('COMMA is ","', () => expect(names[COMMA]).toBe("','")); }); diff --git a/src/lib/editor/lexer-utils.ts b/src/lib/editor/lexer-utils.ts index 9cc903e0..ed399dc0 100644 --- a/src/lib/editor/lexer-utils.ts +++ b/src/lib/editor/lexer-utils.ts @@ -80,6 +80,7 @@ export function readQualifiedName( // Continue only when a `DOT IDENTIFIER` pair follows. Anything else // (end of input, trailing dot, different token) ends the name. + const dotFollows = tokens[pos]?.type === DOT; const identAfterDot = tokens[pos + 1] !== undefined && IDENTIFIER_TOKENS.has(tokens[pos + 1].type); diff --git a/src/lib/server/auth-schema.ts b/src/lib/server/auth-schema.ts new file mode 100644 index 00000000..03855c16 --- /dev/null +++ b/src/lib/server/auth-schema.ts @@ -0,0 +1,95 @@ +import { relations } from 'drizzle-orm'; +import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core'; + +export const user = pgTable('user', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified').default(false).notNull(), + image: text('image'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + username: text('username') +}); + +export const session = pgTable( + 'session', + { + id: text('id').primaryKey(), + expiresAt: timestamp('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + activeStorageConnectionId: text('active_storage_connection_id') + }, + (table) => [index('session_userId_idx').on(table.userId)] +); + +export const account = pgTable( + 'account', + { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: text('scope'), + password: text('password'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull() + }, + (table) => [index('account_userId_idx').on(table.userId)] +); + +export const verification = pgTable( + 'verification', + { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull() + }, + (table) => [index('verification_identifier_idx').on(table.identifier)] +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account) +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id] + }) +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id] + }) +})); diff --git a/src/lib/server/auth-utils.test.ts b/src/lib/server/auth-utils.test.ts new file mode 100644 index 00000000..38f63988 --- /dev/null +++ b/src/lib/server/auth-utils.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { getUserId } from './auth-utils.js'; + +describe('getUserId', () => { + it('returns the user ID when user is present', () => { + const locals = { user: { id: 'user-abc-123' } } as App.Locals; + expect(getUserId(locals)).toBe('user-abc-123'); + }); + + it('returns "anonymous" when user is undefined', () => { + const locals = {} as App.Locals; + expect(getUserId(locals)).toBe('anonymous'); + }); + + it('returns "anonymous" when user has no id', () => { + const locals = { user: {} } as App.Locals; + expect(getUserId(locals)).toBe('anonymous'); + }); +}); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index ae1967a6..f3b6e65c 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,7 +1,10 @@ import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from '@better-auth/drizzle-adapter'; import { genericOAuth, openAPI } from 'better-auth/plugins'; import { sveltekitCookies } from 'better-auth/svelte-kit'; import type { RequestEvent } from '@sveltejs/kit'; +import { db } from './db.js'; +import * as schema from './auth-schema.js'; // Dynamic imports with fallbacks for non-SvelteKit contexts. const envModule = await import('$env/dynamic/private').catch(() => null); @@ -33,8 +36,22 @@ export const oidcEnabled = !!( export const auth = betterAuth({ secret: env.STACKABLE_COCKPIT_SESSION_SECRET, baseURL: env.STACKABLE_COCKPIT_BASE_URL, + database: drizzleAdapter(db, { provider: 'pg', schema }), + account: { + accountLinking: { + enabled: true, + trustedProviders: ['oidc'] + } + }, session: { - cookieCache: { enabled: true, maxAge: 5 * 60 } + cookieCache: { enabled: true, maxAge: 5 * 60 }, + additionalFields: { + activeStorageConnectionId: { + type: 'string', + required: false, + defaultValue: null + } + } }, user: { additionalFields: { @@ -77,9 +94,10 @@ export const auth = betterAuth({ } return { name: profile.name || fullName || profile.preferred_username || profile.email, - email: profile.email || profile.preferred_username, + email: profile.email || '', + emailVerified: profile.email_verified ?? false, image: profile.picture || null, - username: trinoUser || profile.preferred_username || profile.email + username: trinoUser || profile.preferred_username || profile.email || '' }; } } diff --git a/src/lib/server/db-tls.ts b/src/lib/server/db-tls.ts new file mode 100644 index 00000000..49bfad26 --- /dev/null +++ b/src/lib/server/db-tls.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import type { ConnectionOptions } from 'node:tls'; + +// Kept free of SvelteKit imports: drizzle.config.ts uses it outside Vite. + +export const DATABASE_TLS_MODES = ['verify-full', 'require', 'disable'] as const; +export type DatabaseTlsMode = (typeof DATABASE_TLS_MODES)[number]; + +type DatabaseTlsEnv = Record; + +/** + * Resolves the TLS mode for the PostgreSQL connection. Production defaults to + * `verify-full`, everything else to `disable`. + */ +export function databaseTlsMode(env: DatabaseTlsEnv, isProduction: boolean): DatabaseTlsMode { + const raw = env.DATABASE_TLS_MODE?.trim(); + if (!raw) return isProduction ? 'verify-full' : 'disable'; + if (!(DATABASE_TLS_MODES as readonly string[]).includes(raw)) { + throw new Error( + `DATABASE_TLS_MODE must be one of ${DATABASE_TLS_MODES.join(', ')} (got "${raw}")` + ); + } + return raw as DatabaseTlsMode; +} + +/** + * Builds the `ssl` option for node-postgres. + * + * - `verify-full`: TLS with certificate and host name verification. The certificate must chain + * to `DATABASE_TLS_CA_CERT` if set (which replaces the default CAs), otherwise to the CAs + * bundled with Node.js. + * - `require`: TLS without certificate verification (encrypts, but doesn't authenticate the server). + * - `disable`: no TLS. + * + * A CA certificate with any mode but `verify-full` is rejected: it would be ignored silently. + */ +export function databaseSsl(env: DatabaseTlsEnv, mode: DatabaseTlsMode): ConnectionOptions | false { + const caCertPath = env.DATABASE_TLS_CA_CERT?.trim(); + if (caCertPath && mode !== 'verify-full') { + throw new Error( + `DATABASE_TLS_CA_CERT requires DATABASE_TLS_MODE=verify-full (got "${mode}"), otherwise it is ignored` + ); + } + if (mode === 'disable') return false; + return { + rejectUnauthorized: mode === 'verify-full', + // eslint-disable-next-line security/detect-non-literal-fs-filename + ...(caCertPath ? { ca: readFileSync(caCertPath) } : {}) + }; +} diff --git a/src/lib/server/db.test.ts b/src/lib/server/db.test.ts new file mode 100644 index 00000000..4a9dff1d --- /dev/null +++ b/src/lib/server/db.test.ts @@ -0,0 +1,167 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { env, poolOptions, warn } = vi.hoisted(() => ({ + env: {} as Record, + poolOptions: vi.fn(), + warn: vi.fn() +})); + +vi.mock('$env/dynamic/private', () => ({ env })); +vi.mock('./logging', () => ({ + logger: { child: () => ({ warn, debug: vi.fn(), info: vi.fn(), error: vi.fn() }) } +})); +vi.mock('pg', () => ({ + Pool: class { + constructor(options: unknown) { + poolOptions(options); + } + on = vi.fn(); + } +})); +vi.mock('drizzle-orm/node-postgres', () => ({ drizzle: vi.fn() })); + +describe('database environment', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + for (const key of Object.keys(env)) delete env[key]; + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('DATABASE_HOST', 'wrong-process-host'); + vi.stubEnv('DATABASE_PASSWORD', 'wrong-process-password'); + }); + + afterEach(() => vi.unstubAllEnvs()); + + it('uses the database configuration loaded by SvelteKit from .env.development', async () => { + Object.assign(env, { + DATABASE_HOST: 'kind-node', + DATABASE_PORT: '31432', + DATABASE_NAME: 'dev-db', + DATABASE_USER: 'dev-user', + DATABASE_PASSWORD: 'dev-password' + }); + + await import('./db'); + + expect(poolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'kind-node', + port: 31432, + database: 'dev-db', + user: 'dev-user', + password: 'dev-password', + ssl: false + }) + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('validates the SvelteKit password in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + + await expect(import('./db')).rejects.toThrow('DATABASE_PASSWORD must be set in production'); + expect(poolOptions).not.toHaveBeenCalled(); + }); + + describe('TLS', () => { + let tempDir: string; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'db-tls-')); + }); + + afterAll(() => rmSync(tempDir, { recursive: true, force: true })); + + beforeEach(() => { + vi.stubEnv('NODE_ENV', 'production'); + env.DATABASE_PASSWORD = 'prod-password'; + }); + + it('verifies the certificate in production by default', async () => { + await import('./db'); + + expect(poolOptions).toHaveBeenCalledWith( + expect.objectContaining({ ssl: { rejectUnauthorized: true } }) + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('encrypts without verification in require mode and warns', async () => { + env.DATABASE_TLS_MODE = 'require'; + + await import('./db'); + + expect(poolOptions).toHaveBeenCalledWith( + expect.objectContaining({ ssl: { rejectUnauthorized: false } }) + ); + expect(warn).toHaveBeenCalledWith( + { tls_mode: 'require' }, + 'Database TLS certificate verification is disabled' + ); + }); + + it('accepts a mode with surrounding whitespace', async () => { + env.DATABASE_TLS_MODE = ' require '; + + await import('./db'); + + expect(poolOptions).toHaveBeenCalledWith( + expect.objectContaining({ ssl: { rejectUnauthorized: false } }) + ); + }); + + it('can be disabled in production', async () => { + env.DATABASE_TLS_MODE = 'disable'; + + await import('./db'); + + expect(poolOptions).toHaveBeenCalledWith(expect.objectContaining({ ssl: false })); + expect(warn).toHaveBeenCalledWith({ tls_mode: 'disable' }, 'Database TLS is disabled'); + }); + + it('trusts the configured CA certificate', async () => { + const caCertPath = join(tempDir, 'ca.crt'); + // eslint-disable-next-line security/detect-non-literal-fs-filename + writeFileSync(caCertPath, 'test-ca'); + env.DATABASE_TLS_CA_CERT = caCertPath; + + await import('./db'); + + expect(poolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + ssl: { rejectUnauthorized: true, ca: Buffer.from('test-ca') } + }) + ); + }); + + it('fails when the CA certificate file is missing', async () => { + env.DATABASE_TLS_CA_CERT = join(tempDir, 'missing.crt'); + + await expect(import('./db')).rejects.toThrow('ENOENT'); + expect(poolOptions).not.toHaveBeenCalled(); + }); + + it.each(['require', 'disable'])( + 'rejects a CA certificate with mode %s, which would ignore it', + async (mode) => { + env.DATABASE_TLS_MODE = mode; + env.DATABASE_TLS_CA_CERT = join(tempDir, 'ca.crt'); + + await expect(import('./db')).rejects.toThrow( + 'DATABASE_TLS_CA_CERT requires DATABASE_TLS_MODE=verify-full' + ); + expect(poolOptions).not.toHaveBeenCalled(); + } + ); + + it('rejects an unknown mode', async () => { + env.DATABASE_TLS_MODE = 'prefer'; + + await expect(import('./db')).rejects.toThrow('DATABASE_TLS_MODE must be one of'); + expect(poolOptions).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 00000000..f1bb4999 --- /dev/null +++ b/src/lib/server/db.ts @@ -0,0 +1,71 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import { logger } from './logging'; +import { databaseSsl, databaseTlsMode } from './db-tls'; + +const log = logger.child({ module: 'database' }); + +// Vite loads .env.development into SvelteKit's env, not process.env. +// The fallback also supports the standalone migration CLI used by dev/setup.sh. +const envModule = await import('$env/dynamic/private').catch(() => null); +const env = envModule?.env ?? process.env; + +const isProduction = process.env.NODE_ENV === 'production'; + +if (isProduction && !env.DATABASE_PASSWORD) { + throw new Error('DATABASE_PASSWORD must be set in production'); +} + +// Parse connection credentials from environment variables +const dbHost = env.DATABASE_HOST || 'localhost'; +const dbPort = parseInt(env.DATABASE_PORT || '31432', 10); +const dbName = env.DATABASE_NAME || 'cockpit'; +const dbUser = env.DATABASE_USER || 'cockpit'; +if (!env.DATABASE_PASSWORD) { + log.warn('DATABASE_PASSWORD not set, using default development password'); +} +const dbPassword = env.DATABASE_PASSWORD || 'cockpit-dev-password'; + +// DATABASE_TLS_MODE: verify-full (production default), require or disable (development default). +// For a private CA, set DATABASE_TLS_CA_CERT to the mounted CA certificate path. +const tlsMode = databaseTlsMode(env, isProduction); +if (isProduction && tlsMode !== 'verify-full') { + log.warn( + { tls_mode: tlsMode }, + tlsMode === 'disable' + ? 'Database TLS is disabled' + : 'Database TLS certificate verification is disabled' + ); +} +log.debug({ tls_mode: tlsMode, tls_ca_cert: env.DATABASE_TLS_CA_CERT || null }, 'Database TLS'); +const sslMode = databaseSsl(env, tlsMode); + +// Create a connection pool +const pool = new Pool({ + host: dbHost, + port: dbPort, + database: dbName, + user: dbUser, + password: dbPassword, + ssl: sslMode, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000 +}); + +pool.on('error', (err) => { + log.error({ error: err }, 'Unexpected error on idle client'); +}); + +// Create Drizzle instance +export const db = drizzle({ client: pool }); + +// Graceful shutdown +export async function closeDb(): Promise { + try { + await pool.end(); + log.info('Database pool closed'); + } catch (error) { + log.error({ error }, 'Error closing database pool'); + } +} diff --git a/src/lib/server/feature-flags.test.ts b/src/lib/server/feature-flags.test.ts new file mode 100644 index 00000000..78223427 --- /dev/null +++ b/src/lib/server/feature-flags.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + +import { parseParquetDisallowed, resolveArchiveCacheMaxBytes } from './feature-flags.js'; + +const MB = 1024 * 1024; + +describe('resolveArchiveCacheMaxBytes', () => { + it('keeps a limit of at least twice the preview limit', () => { + expect(resolveArchiveCacheMaxBytes(512 * MB, 100 * MB)).toBe(512 * MB); + expect(resolveArchiveCacheMaxBytes(200 * MB, 100 * MB)).toBe(200 * MB); + }); + + it('raises a lower limit to twice the preview limit', () => { + expect(resolveArchiveCacheMaxBytes(150 * MB, 100 * MB)).toBe(200 * MB); + }); +}); + +describe('parseParquetDisallowed', () => { + it('defaults to GZIP with requireOffsetIndex when value is undefined', () => { + expect(parseParquetDisallowed(undefined)).toEqual([ + { codec: 'GZIP', requireOffsetIndex: true } + ]); + }); + + it('defaults to GZIP with requireOffsetIndex when value is empty string', () => { + expect(parseParquetDisallowed('')).toEqual([{ codec: 'GZIP', requireOffsetIndex: true }]); + }); + + it('parses a single codec without suffix', () => { + expect(parseParquetDisallowed('zstd')).toEqual([{ codec: 'ZSTD', requireOffsetIndex: false }]); + }); + + it('parses a codec with -no_offset suffix', () => { + expect(parseParquetDisallowed('gzip-no_offset')).toEqual([ + { codec: 'GZIP', requireOffsetIndex: true } + ]); + }); + + it('parses comma-separated list with mixed suffixes', () => { + expect(parseParquetDisallowed('gzip-no_offset,zstd,snappy-no_offset')).toEqual([ + { codec: 'GZIP', requireOffsetIndex: true }, + { codec: 'ZSTD', requireOffsetIndex: false }, + { codec: 'SNAPPY', requireOffsetIndex: true } + ]); + }); + + it('converts codec names to uppercase', () => { + expect(parseParquetDisallowed('Gzip,Zstd')).toEqual([ + { codec: 'GZIP', requireOffsetIndex: false }, + { codec: 'ZSTD', requireOffsetIndex: false } + ]); + }); + + it('filters out empty entries', () => { + expect(parseParquetDisallowed('gzip,,zstd')).toEqual([ + { codec: 'GZIP', requireOffsetIndex: false }, + { codec: 'ZSTD', requireOffsetIndex: false } + ]); + }); + + it('trims whitespace', () => { + expect(parseParquetDisallowed(' gzip , zstd ')).toEqual([ + { codec: 'GZIP', requireOffsetIndex: false }, + { codec: 'ZSTD', requireOffsetIndex: false } + ]); + }); +}); diff --git a/src/lib/server/feature-flags.ts b/src/lib/server/feature-flags.ts index dd0d071f..f175feef 100644 --- a/src/lib/server/feature-flags.ts +++ b/src/lib/server/feature-flags.ts @@ -2,6 +2,9 @@ // from the live process env so consumers don't repeat the env-name + parsing. import { env } from '$env/dynamic/private'; +import { logger } from '$lib/server/logging'; + +const log = logger.child({ module: 'feature-flags' }); /** When `STACKABLE_COCKPIT_COMPLETION_ENABLED=false`, the SQL editor's * code-completion provider is not registered and the metadata endpoint @@ -20,7 +23,8 @@ export const storageBrowserEnabled = env.STACKABLE_COCKPIT_STORAGE_BROWSER_ENABL /** Maximum bytes fetched when streaming a text, CSV, or JSON file preview. * Controlled by `STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES`. Default: 262144 (256 KiB). * Larger values allow more of a file to be visible in-browser but increase - * memory pressure and response latency. */ + * memory pressure and response latency. Only applies to files larger than + * `maxEditableFileSize`; editable files are always fetched completely. */ export const textPreviewBytes = parseInt(env.STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES ?? '', 10) || 256 * 1024; @@ -51,3 +55,114 @@ export const filePreviewRows = parseInt(env.STACKABLE_COCKPIT_FILE_PREVIEW_ROWS * Values only affect client side rendering and not payload size. */ export const filePreviewColumns = parseInt(env.STACKABLE_COCKPIT_FILE_PREVIEW_COLUMNS ?? '', 10) || 50; + +/** Maximum compressed size of an archive that will be opened for in-browser + * preview. Archives larger than this threshold will not be downloaded at all + * and a "too large" fallback is shown instead. During listing the total + * decompressed entry size is also checked against this limit. + * Controlled by `STACKABLE_COCKPIT_ARCHIVE_PREVIEW_MAX_MB`. Default: 100 MB. */ +export const archivePreviewMaxBytes = + parseInt(env.STACKABLE_COCKPIT_ARCHIVE_PREVIEW_MAX_MB ?? '', 10) * 1024 * 1024 || + 100 * 1024 * 1024; + +/** Maximum total size of the archive files cached on local disk (`/tmp`) for + * archive previews, nested archives included. When the cache is full, the + * least recently used idle archive is evicted; if every cached archive is in + * use, the request is rejected. Never lower than twice `archivePreviewMaxBytes`: + * a nested archive preview keeps the outer archive cached while the nested one + * is written, so it needs room for both. + * Controlled by `STACKABLE_COCKPIT_ARCHIVE_CACHE_MAX_MB`. Default: 512 MB. */ +export const archiveCacheMaxBytes = resolveArchiveCacheMaxBytes( + parseInt(env.STACKABLE_COCKPIT_ARCHIVE_CACHE_MAX_MB ?? '', 10) * 1024 * 1024 || 512 * 1024 * 1024, + archivePreviewMaxBytes +); + +export function resolveArchiveCacheMaxBytes(configured: number, previewMaxBytes: number): number { + const minimum = 2 * previewMaxBytes; + if (configured >= minimum) return configured; + log.warn( + { configured_bytes: configured, minimum_bytes: minimum }, + 'archive cache limit is below twice the archive preview limit, raising it' + ); + return minimum; +} + +// ── Parquet preview restrictions ─────────────────────────────────────────── + +export interface ParquetDisallowedCompression { + /** Upper-case compression codec name (e.g. `GZIP`, `ZSTD`, `SNAPPY`). */ + codec: string; + /** When true, only block files using this codec if they lack an offset index. */ + requireOffsetIndex: boolean; +} + +export function parseParquetDisallowed(value: string | undefined): ParquetDisallowedCompression[] { + if (!value) return [{ codec: 'GZIP', requireOffsetIndex: true }]; + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map((entry) => { + if (entry.endsWith('-no_offset')) { + return { + codec: entry.slice(0, -'-no_offset'.length).toUpperCase(), + requireOffsetIndex: true + }; + } + return { codec: entry.toUpperCase(), requireOffsetIndex: false }; + }); +} + +/** Comma-separated list of compression types to disallow from parquet + * data preview. Each entry is either a compression codec name (e.g. `gzip`, + * `zstd`, `snappy`) or a codec suffixed with `-no_offset` (e.g. + * `gzip-no_offset`) to only block when the file lacks an offset index. + * Controlled by `STACKABLE_COCKPIT_PARQUET_PREVIEW_DISALLOWED_COMPRESSION_TYPES`. + * Default: `gzip-no_offset`. */ +export const parquetDisallowedCompression = parseParquetDisallowed( + env.STACKABLE_COCKPIT_PARQUET_PREVIEW_DISALLOWED_COMPRESSION_TYPES ?? 'gzip-no_offset' +); + +// ── Flags shared with the client ───────────────────────────────────────────── +// +// These are `PUBLIC_` settings that the server enforces too. They are defined +// once in `$lib/client/feature-flags` so both sides parse them identically. +// +// `maxEditableFileSize`: save requests above it are rejected with HTTP 413. +// +// Context actions: server-side enforcement (cost control, not authorisation) on +// `POST /api/storage/transfer` and its `/plan`, by the request's `op`: +// - `copy` requires paste. +// - `move` requires move or paste, because drag-and-drop and cut-and-paste +// both move. +// - `rename` requires rename. +// Cut/copy only fill the client clipboard and have no endpoint of their own. +export { + infiniteScrollEnabled, + maxEditableFileSize, + storageCutCopyEnabled, + storagePasteEnabled, + storageRenameEnabled, + storageMoveEnabled +} from '$lib/client/feature-flags.js'; + +/** Objects copied at once within one transfer request (copy, move, rename). + * Controlled by `STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY`. Default: 8; + * a missing or invalid value falls back to the default. */ +export const storageTransferConcurrency: number = (() => { + // Number() rather than parseInt, so "8x" or "1.5" fall back instead of being truncated. + const parsed = Number(env.STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY?.trim() || NaN); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 8; +})(); + +// ── Storage browser: downloads ───────────────────────────────────────────── + +/** Days that a user's download history remains available. Controlled by + * `STACKABLE_COCKPIT_DOWNLOAD_HISTORY_RETENTION_DAYS`. */ +const retentionDays = Number(env.STACKABLE_COCKPIT_DOWNLOAD_HISTORY_RETENTION_DAYS ?? 30); +if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 3650) { + throw new Error( + 'STACKABLE_COCKPIT_DOWNLOAD_HISTORY_RETENTION_DAYS must be an integer between 1 and 3650' + ); +} +export const downloadHistoryRetentionMs = retentionDays * 24 * 60 * 60 * 1000; diff --git a/src/lib/server/metrics.ts b/src/lib/server/metrics.ts index b11cf821..12101b66 100644 --- a/src/lib/server/metrics.ts +++ b/src/lib/server/metrics.ts @@ -24,3 +24,52 @@ export const trinoActiveQueries = new Gauge({ help: 'Number of active Trino queries submitted through Stackable UI', registers: [register] }); + +export const storageSearchTotal = new Counter({ + name: 'storage_search_total', + help: 'Bucket-scoped storage searches executed through the Stackable UI', + labelNames: ['outcome'], + registers: [register] +}); + +export const storageSearchHistoryTotal = new Counter({ + name: 'storage_search_history_total', + help: 'Recent storage search history operations executed through the Stackable UI', + labelNames: ['operation', 'outcome'], + registers: [register] +}); + +export const storageTransferItemsTotal = new Counter({ + name: 'storage_transfer_items_total', + help: 'Selected items of storage copy, move and rename transfers, by final status', + labelNames: ['op', 'status'], + registers: [register] +}); + +export const storageTransferDuration = new Histogram({ + name: 'storage_transfer_duration_seconds', + help: 'Duration of storage copy, move and rename transfers in seconds', + labelNames: ['op'], + buckets: [1, 10, 60, 300, 1800], + registers: [register] +}); + +export const storageDirectorySizeCalculationsTotal = new Counter({ + name: 'storage_directory_size_calculations_total', + help: 'Directory size calculations, by outcome (complete, truncated, aborted, error)', + labelNames: ['outcome'], + registers: [register] +}); + +export const storageArchiveCacheBytes = new Gauge({ + name: 'storage_archive_cache_bytes', + help: 'Bytes of archive files cached on local disk for archive previews', + registers: [register] +}); + +export const storageArchiveCacheEvictionsTotal = new Counter({ + name: 'storage_archive_cache_evictions_total', + help: 'Archive files removed from the local archive preview cache, by reason', + labelNames: ['reason'], + registers: [register] +}); diff --git a/src/lib/server/migrate.ts b/src/lib/server/migrate.ts new file mode 100644 index 00000000..1758acb4 --- /dev/null +++ b/src/lib/server/migrate.ts @@ -0,0 +1,36 @@ +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { db } from './db.js'; +import { logger } from './logging/index.js'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const log = logger.child({ module: 'migrations' }); + +function defaultMigrationsFolder(): string { + const shippedMigrations = resolve('migrations'); + return existsSync(shippedMigrations) ? shippedMigrations : resolve('src/lib/server/migrations'); +} + +export async function runMigrations(migrationsFolder = defaultMigrationsFolder()) { + try { + log.info({ migrations_folder: migrationsFolder }, 'Running database migrations...'); + await migrate(db, { migrationsFolder }); + log.info('Database migrations completed successfully'); + return true; + } catch (error) { + log.error({ err: error, migrations_folder: migrationsFolder }, 'Database migrations failed'); + return false; + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + runMigrations() + .then((success) => { + process.exit(success ? 0 : 1); + }) + .catch((error) => { + console.error('Migration error:', error); + process.exit(1); + }); +} diff --git a/src/lib/server/migrations/0000_initial_schema.sql b/src/lib/server/migrations/0000_initial_schema.sql new file mode 100644 index 00000000..c61520f9 --- /dev/null +++ b/src/lib/server/migrations/0000_initial_schema.sql @@ -0,0 +1,68 @@ +CREATE TABLE "user_storage_connections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "name" text NOT NULL, + "encrypted_payload" text NOT NULL, + "hash" text NOT NULL, + "additional_buckets" jsonb DEFAULT '[]' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_storage_connections_user_id_name_unique" UNIQUE("user_id","name") +); +--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "active_storage_connection_id" text, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "username" text, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_id_idx" ON "user_storage_connections" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); diff --git a/src/lib/server/migrations/0001_common_spot.sql b/src/lib/server/migrations/0001_common_spot.sql new file mode 100644 index 00000000..01a7a5db --- /dev/null +++ b/src/lib/server/migrations/0001_common_spot.sql @@ -0,0 +1,30 @@ +CREATE TABLE "storage_download_manifests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "connection_id" uuid NOT NULL, + "bucket" text NOT NULL, + "prefix" text NOT NULL, + "entries" jsonb NOT NULL, + "format" text NOT NULL, + "archive" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_recent_searches" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "connection_id" uuid NOT NULL, + "buckets" jsonb DEFAULT '[]'::jsonb NOT NULL, + "query" text NOT NULL, + "use_regex" boolean DEFAULT false NOT NULL, + "exclude_patterns" text[] DEFAULT '{}' NOT NULL, + "search_path" text DEFAULT '' NOT NULL, + "max_depth" integer, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_recent_searches_connection_query" UNIQUE NULLS NOT DISTINCT("user_id","connection_id","query","use_regex","exclude_patterns","search_path","max_depth","buckets") +); +--> statement-breakpoint +ALTER TABLE "user_recent_searches" ADD CONSTRAINT "user_recent_searches_connection_id_user_storage_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."user_storage_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "storage_download_manifests_user_expiry_idx" ON "storage_download_manifests" USING btree ("user_id","expires_at");--> statement-breakpoint +CREATE INDEX "user_recent_searches_connection_idx" ON "user_recent_searches" USING btree ("user_id","connection_id"); diff --git a/src/lib/server/migrations/0002_hard_sleepwalker.sql b/src/lib/server/migrations/0002_hard_sleepwalker.sql new file mode 100644 index 00000000..44316b51 --- /dev/null +++ b/src/lib/server/migrations/0002_hard_sleepwalker.sql @@ -0,0 +1,3 @@ +DELETE FROM "storage_download_manifests" WHERE "connection_id" NOT IN (SELECT "id" FROM "user_storage_connections");--> statement-breakpoint +ALTER TABLE "storage_download_manifests" ADD CONSTRAINT "storage_download_manifests_connection_id_user_storage_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."user_storage_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "storage_download_manifests_expiry_idx" ON "storage_download_manifests" USING btree ("expires_at"); diff --git a/src/lib/server/migrations/0003_recent_search_key.sql b/src/lib/server/migrations/0003_recent_search_key.sql new file mode 100644 index 00000000..01b74f4a --- /dev/null +++ b/src/lib/server/migrations/0003_recent_search_key.sql @@ -0,0 +1,6 @@ +ALTER TABLE "user_recent_searches" DROP CONSTRAINT "user_recent_searches_connection_query";--> statement-breakpoint +ALTER TABLE "user_recent_searches" ADD COLUMN "search_key" text;--> statement-breakpoint +-- Existing rows get a unique placeholder key; they no longer deduplicate with new searches. +UPDATE "user_recent_searches" SET "search_key" = "id"::text;--> statement-breakpoint +ALTER TABLE "user_recent_searches" ALTER COLUMN "search_key" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "user_recent_searches" ADD CONSTRAINT "user_recent_searches_search_key" UNIQUE("user_id","connection_id","search_key"); diff --git a/src/lib/server/migrations/meta/0000_snapshot.json b/src/lib/server/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..a44a2f7a --- /dev/null +++ b/src/lib/server/migrations/meta/0000_snapshot.json @@ -0,0 +1,461 @@ +{ + "id": "28c7ad64-8ceb-4a76-a921-9fd5ff72e9c4", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_storage_connections": { + "name": "user_storage_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "additional_buckets": { + "name": "additional_buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_storage_connections_user_id_name_unique": { + "name": "user_storage_connections_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_storage_connection_id": { + "name": "active_storage_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/server/migrations/meta/0001_snapshot.json b/src/lib/server/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..b800842b --- /dev/null +++ b/src/lib/server/migrations/meta/0001_snapshot.json @@ -0,0 +1,684 @@ +{ + "id": "12394c95-3885-473d-bbee-f4180822209b", + "prevId": "28c7ad64-8ceb-4a76-a921-9fd5ff72e9c4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.storage_download_manifests": { + "name": "storage_download_manifests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archive": { + "name": "archive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "storage_download_manifests_user_expiry_idx": { + "name": "storage_download_manifests_user_expiry_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_recent_searches": { + "name": "user_recent_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "buckets": { + "name": "buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_regex": { + "name": "use_regex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "search_path": { + "name": "search_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "max_depth": { + "name": "max_depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_recent_searches_connection_idx": { + "name": "user_recent_searches_connection_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_recent_searches_connection_id_user_storage_connections_id_fk": { + "name": "user_recent_searches_connection_id_user_storage_connections_id_fk", + "tableFrom": "user_recent_searches", + "tableTo": "user_storage_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_recent_searches_connection_query": { + "name": "user_recent_searches_connection_query", + "nullsNotDistinct": true, + "columns": [ + "user_id", + "connection_id", + "query", + "use_regex", + "exclude_patterns", + "search_path", + "max_depth", + "buckets" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_storage_connections": { + "name": "user_storage_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "additional_buckets": { + "name": "additional_buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_storage_connections_user_id_name_unique": { + "name": "user_storage_connections_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_storage_connection_id": { + "name": "active_storage_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/server/migrations/meta/0002_snapshot.json b/src/lib/server/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..cb745d21 --- /dev/null +++ b/src/lib/server/migrations/meta/0002_snapshot.json @@ -0,0 +1,709 @@ +{ + "id": "7c1e18db-c715-4d6d-80e9-0987854c83ac", + "prevId": "12394c95-3885-473d-bbee-f4180822209b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.storage_download_manifests": { + "name": "storage_download_manifests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archive": { + "name": "archive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "storage_download_manifests_user_expiry_idx": { + "name": "storage_download_manifests_user_expiry_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storage_download_manifests_expiry_idx": { + "name": "storage_download_manifests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_download_manifests_connection_id_user_storage_connections_id_fk": { + "name": "storage_download_manifests_connection_id_user_storage_connections_id_fk", + "tableFrom": "storage_download_manifests", + "tableTo": "user_storage_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_recent_searches": { + "name": "user_recent_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "buckets": { + "name": "buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_regex": { + "name": "use_regex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "search_path": { + "name": "search_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "max_depth": { + "name": "max_depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_recent_searches_connection_idx": { + "name": "user_recent_searches_connection_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_recent_searches_connection_id_user_storage_connections_id_fk": { + "name": "user_recent_searches_connection_id_user_storage_connections_id_fk", + "tableFrom": "user_recent_searches", + "tableTo": "user_storage_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_recent_searches_connection_query": { + "name": "user_recent_searches_connection_query", + "nullsNotDistinct": true, + "columns": [ + "user_id", + "connection_id", + "query", + "use_regex", + "exclude_patterns", + "search_path", + "max_depth", + "buckets" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_storage_connections": { + "name": "user_storage_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "additional_buckets": { + "name": "additional_buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_storage_connections_user_id_name_unique": { + "name": "user_storage_connections_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_storage_connection_id": { + "name": "active_storage_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/server/migrations/meta/0003_snapshot.json b/src/lib/server/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..107ec55c --- /dev/null +++ b/src/lib/server/migrations/meta/0003_snapshot.json @@ -0,0 +1,706 @@ +{ + "id": "2a7682cb-b94c-4e44-8ad8-06bead816e8b", + "prevId": "7c1e18db-c715-4d6d-80e9-0987854c83ac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.storage_download_manifests": { + "name": "storage_download_manifests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archive": { + "name": "archive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "storage_download_manifests_user_expiry_idx": { + "name": "storage_download_manifests_user_expiry_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storage_download_manifests_expiry_idx": { + "name": "storage_download_manifests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_download_manifests_connection_id_user_storage_connections_id_fk": { + "name": "storage_download_manifests_connection_id_user_storage_connections_id_fk", + "tableFrom": "storage_download_manifests", + "tableTo": "user_storage_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_recent_searches": { + "name": "user_recent_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "buckets": { + "name": "buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_regex": { + "name": "use_regex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "search_path": { + "name": "search_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "max_depth": { + "name": "max_depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "search_key": { + "name": "search_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_recent_searches_connection_idx": { + "name": "user_recent_searches_connection_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_recent_searches_connection_id_user_storage_connections_id_fk": { + "name": "user_recent_searches_connection_id_user_storage_connections_id_fk", + "tableFrom": "user_recent_searches", + "tableTo": "user_storage_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_recent_searches_search_key": { + "name": "user_recent_searches_search_key", + "nullsNotDistinct": false, + "columns": ["user_id", "connection_id", "search_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_storage_connections": { + "name": "user_storage_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "additional_buckets": { + "name": "additional_buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_storage_connections_user_id_name_unique": { + "name": "user_storage_connections_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_storage_connection_id": { + "name": "active_storage_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/server/migrations/meta/_journal.json b/src/lib/server/migrations/meta/_journal.json new file mode 100644 index 00000000..b5290ed9 --- /dev/null +++ b/src/lib/server/migrations/meta/_journal.json @@ -0,0 +1,34 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1789716908296, + "tag": "0000_initial_schema", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791377006309, + "tag": "0001_common_spot", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1791468385760, + "tag": "0002_hard_sleepwalker", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1791810607788, + "tag": "0003_recent_search_key", + "breakpoints": true + } + ] +} diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts new file mode 100644 index 00000000..b65acb17 --- /dev/null +++ b/src/lib/server/schema.ts @@ -0,0 +1,93 @@ +import { + pgTable, + text, + timestamp, + uuid, + jsonb, + index, + unique, + boolean, + integer +} from 'drizzle-orm/pg-core'; + +/** + * User storage connections table. + * Stores encrypted credentials and configuration for S3-compatible storage buckets. + * Multiple connections can be stored per user. + */ +export const userStorageConnections = pgTable( + 'user_storage_connections', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + // Display name for this connection (e.g., "Primary S3", "Backup Storage") + name: text('name').notNull(), + // Encrypted payload containing connection details and hash + encryptedPayload: text('encrypted_payload').notNull(), + hash: text('hash').notNull(), + // Array of additional bucket names accessible with these credentials + additionalBuckets: jsonb('additional_buckets').notNull().default('[]'), + createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { mode: 'date' }).defaultNow().notNull() + }, + (table) => [ + index('user_id_idx').on(table.userId), + unique('user_storage_connections_user_id_name_unique').on(table.userId, table.name) + ] +); + +/** + * Recent storage searches per user and connection. Buckets are sorted before + * persistence so their order does not affect the unique search identity. + * Uniqueness is enforced on a hash of the search parameters because the + * parameters themselves can exceed the btree index row size limit. + */ +export const userRecentSearches = pgTable( + 'user_recent_searches', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + connectionId: uuid('connection_id') + .notNull() + .references(() => userStorageConnections.id, { onDelete: 'cascade' }), + buckets: jsonb('buckets').$type().notNull().default([]), + query: text('query').notNull(), + useRegex: boolean('use_regex').notNull().default(false), + excludePatterns: text('exclude_patterns').array().notNull().default([]), + searchPath: text('search_path').notNull().default(''), + maxDepth: integer('max_depth'), + // SHA-256 of the buckets, query and advanced options (see recent-searches-db.ts). + searchKey: text('search_key').notNull(), + updatedAt: timestamp('updated_at', { mode: 'date' }).defaultNow().notNull() + }, + (table) => [ + unique('user_recent_searches_search_key').on(table.userId, table.connectionId, table.searchKey), + index('user_recent_searches_connection_idx').on(table.userId, table.connectionId) + ] +); + +/** + * Immutable user download-history metadata. Connection credentials remain solely + * in user_storage_connections. + */ +export const storageDownloadManifests = pgTable( + 'storage_download_manifests', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + connectionId: uuid('connection_id') + .notNull() + .references(() => userStorageConnections.id, { onDelete: 'cascade' }), + bucket: text('bucket').notNull(), + prefix: text('prefix').notNull(), + entries: jsonb('entries').notNull(), + format: text('format').notNull(), + archive: text('archive'), + createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(), + expiresAt: timestamp('expires_at', { mode: 'date' }).notNull() + }, + (table) => [ + index('storage_download_manifests_user_expiry_idx').on(table.userId, table.expiresAt), + index('storage_download_manifests_expiry_idx').on(table.expiresAt) + ] +); diff --git a/src/lib/server/storage/archive.test.ts b/src/lib/server/storage/archive.test.ts new file mode 100644 index 00000000..dd366a5a --- /dev/null +++ b/src/lib/server/storage/archive.test.ts @@ -0,0 +1,902 @@ +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import { readFileSync, readdirSync, statSync, unlinkSync, existsSync, mkdtempSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { threadId } from 'node:worker_threads'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import AdmZip from 'adm-zip'; +import * as tar from 'tar-stream'; +import { createWriteStream } from 'node:fs'; +import { createGzip } from 'node:zlib'; + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + +const DEFAULT_CACHE_MAX_BYTES = 512 * 1024 * 1024; +const flags = vi.hoisted(() => ({ archiveCacheMaxBytes: 512 * 1024 * 1024 })); +vi.mock('$lib/server/feature-flags.js', () => ({ + get archiveCacheMaxBytes() { + return flags.archiveCacheMaxBytes; + } +})); + +import { + getArchiveFormat, + listArchiveContents, + extractArchiveEntry, + clearArchiveCache, + archiveCacheBytes, + ArchiveCacheFullError, + type ArchiveDownloadFn +} from './archive.js'; +import { storageArchiveCacheEvictionsTotal } from '$lib/server/metrics.js'; + +const testDir = mkdtempSync(join(tmpdir(), 'archive-test-')); +const cleanupPaths: string[] = []; + +function testPath(name: string): string { + const p = join(testDir, name); + cleanupPaths.push(p); + return p; +} + +function makeZip(path: string, entries: Record): void { + const zip = new AdmZip(); + for (const [name, content] of Object.entries(entries)) { + if (typeof content === 'string') { + zip.addFile(name, Buffer.from(content, 'utf-8')); + } else { + zip.addFile(name, content); + } + } + zip.writeZip(path); +} + +function makeTar(path: string, entries: Record): Promise { + return new Promise((resolve, reject) => { + const pack = tar.pack(); + // eslint-disable-next-line security/detect-non-literal-fs-filename + const ws = createWriteStream(path); + pack.pipe(ws); + for (const [name, content] of Object.entries(entries)) { + pack.entry({ name }, Buffer.from(content, 'utf-8')); + } + pack.finalize(); + ws.on('finish', resolve); + ws.on('error', reject); + }); +} + +function makeTarGz(path: string, entries: Record): Promise { + return new Promise((resolve, reject) => { + const pack = tar.pack(); + const gz = createGzip(); + // eslint-disable-next-line security/detect-non-literal-fs-filename + const ws = createWriteStream(path); + pack.pipe(gz).pipe(ws); + for (const [name, content] of Object.entries(entries)) { + pack.entry({ name }, Buffer.from(content, 'utf-8')); + } + pack.finalize(); + ws.on('finish', resolve); + ws.on('error', reject); + }); +} + +function makeNestedZip(): string { + const innerZip = new AdmZip(); + innerZip.addFile('nested.txt', Buffer.from('nested content', 'utf-8')); + const innerPath = testPath('inner.zip'); + innerZip.writeZip(innerPath); + + const outerZip = new AdmZip(); + outerZip.addFile('top.txt', Buffer.from('top content', 'utf-8')); + outerZip.addLocalFile(innerPath, 'archives/'); + const outerPath = testPath('outer.zip'); + outerZip.writeZip(outerPath); + return outerPath; +} + +/** HEAD stub reporting a fixed ETag, so the archive is cached. */ +function head(etag = '"v1"', size = 1024) { + return vi.fn(async () => ({ size, etag })); +} + +function bufferStream(data: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(data)); + controller.close(); + } + }); +} + +const dummyDownloadFn: ArchiveDownloadFn = (key: string) => + // eslint-disable-next-line security/detect-non-literal-fs-filename + Promise.resolve({ stream: bufferStream(readFileSync(key)) }); + +describe('getArchiveFormat', () => { + it('detects .zip', () => { + expect(getArchiveFormat('archive.zip')).toBe('zip'); + }); + + it('detects .tar.gz', () => { + expect(getArchiveFormat('archive.tar.gz')).toBe('tar.gz'); + }); + + it('detects .tgz', () => { + expect(getArchiveFormat('archive.tgz')).toBe('tgz'); + }); + + it('detects .tar', () => { + expect(getArchiveFormat('archive.tar')).toBe('tar'); + }); + + it('does not support RAR or 7z archives', () => { + expect(getArchiveFormat('archive.rar')).toBeNull(); + expect(getArchiveFormat('ARCHIVE.RAR')).toBeNull(); + expect(getArchiveFormat('archive.7z')).toBeNull(); + }); + + it('returns null for non-archive files', () => { + expect(getArchiveFormat('readme.txt')).toBeNull(); + expect(getArchiveFormat('script.js')).toBeNull(); + expect(getArchiveFormat('data.csv')).toBeNull(); + }); + + it('is case-insensitive', () => { + expect(getArchiveFormat('ARCHIVE.ZIP')).toBe('zip'); + expect(getArchiveFormat('Archive.Tar.Gz')).toBe('tar.gz'); + }); +}); + +describe('listArchiveContents', () => { + beforeEach(() => { + clearArchiveCache(); + }); + + afterAll(() => { + clearArchiveCache(); + for (const p of cleanupPaths) { + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (existsSync(p)) unlinkSync(p); + } catch { + /* noop */ + } + } + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (existsSync(testDir)) unlinkSync(testDir); + } catch { + /* noop */ + } + }); + + describe('ZIP', () => { + it('lists top-level files and directories', async () => { + const path = testPath('test.zip'); + makeZip(path, { + 'README.md': '# Test', + 'src/index.js': 'console.log("hello")', + 'src/lib/util.js': 'export const x = 1', + 'docs/guide.md': '# Guide' + }); + + const listing = await listArchiveContents('test-bucket', path, '', dummyDownloadFn, head()); + + const dirs = listing.entries.filter((e) => e.isDirectory).map((e) => e.key); + const files = listing.entries.filter((e) => !e.isDirectory).map((e) => e.key); + + expect(dirs.sort()).toEqual(['docs/', 'src/']); + expect(files).toEqual(['README.md']); + }); + + it('lists contents under an internal prefix', async () => { + const path = testPath('nested.zip'); + makeZip(path, { + 'a/b/c/file1.txt': 'one', + 'a/b/c/file2.txt': 'two', + 'a/b/other.md': 'other', + 'a/top.txt': 'top' + }); + + const listing = await listArchiveContents( + 'test-bucket', + path, + 'a/b/', + dummyDownloadFn, + head() + ); + + const dirs = listing.entries.filter((e) => e.isDirectory).map((e) => e.key); + const files = listing.entries.filter((e) => !e.isDirectory).map((e) => e.key); + + expect(dirs.sort()).toEqual(['c/']); + expect(files).toEqual(['other.md']); + }); + + it('filters out deep nested entries at prefix root', async () => { + const path = testPath('deep.zip'); + makeZip(path, { + 'top.txt': 'top', + 'alpha/beta/gamma/deep.txt': 'deep', + 'alpha/beta/other.txt': 'other', + 'alpha/surface.txt': 'surface' + }); + + const listing = await listArchiveContents( + 'test-bucket', + path, + 'alpha/', + dummyDownloadFn, + head() + ); + + const dirs = listing.entries.filter((e) => e.isDirectory).map((e) => e.key); + const files = listing.entries.filter((e) => !e.isDirectory).map((e) => e.key); + + expect(dirs).toEqual(['beta/']); + expect(files).toEqual(['surface.txt']); + }); + + it('handles empty archive', async () => { + const path = testPath('empty.zip'); + makeZip(path, {}); + + const listing = await listArchiveContents('test-bucket', path, '', dummyDownloadFn, head()); + + expect(listing.entries).toHaveLength(0); + }); + + it('handles prefix with no matches', async () => { + const path = testPath('nomatch.zip'); + makeZip(path, { 'only.txt': 'content' }); + + const listing = await listArchiveContents( + 'test-bucket', + path, + 'nonexistent/', + dummyDownloadFn, + head() + ); + + expect(listing.entries).toHaveLength(0); + }); + }); + + describe('TAR', () => { + it('lists top-level files and directories', async () => { + const path = testPath('test.tar'); + await makeTar(path, { + 'README.md': '# Test', + 'src/index.js': 'console.log("hello")', + 'src/lib/util.js': 'export const x = 1', + 'docs/guide.md': '# Guide' + }); + + const listing = await listArchiveContents('test-bucket', path, '', dummyDownloadFn, head()); + + const dirs = listing.entries.filter((e) => e.isDirectory).map((e) => e.key); + const files = listing.entries.filter((e) => !e.isDirectory).map((e) => e.key); + + expect(dirs.sort()).toEqual(['docs/', 'src/']); + expect(files).toEqual(['README.md']); + }); + + it('lists contents under an internal prefix', async () => { + const path = testPath('nested.tar'); + await makeTar(path, { + 'x/y/z/data.txt': 'data', + 'x/y/other.txt': 'other', + 'x/root.txt': 'root' + }); + + const listing = await listArchiveContents( + 'test-bucket', + path, + 'x/y/', + dummyDownloadFn, + head() + ); + + const dirs = listing.entries.filter((e) => e.isDirectory).map((e) => e.key); + const files = listing.entries.filter((e) => !e.isDirectory).map((e) => e.key); + + expect(dirs).toEqual(['z/']); + expect(files).toEqual(['other.txt']); + }); + }); + + describe('TAR.GZ', () => { + it('lists contents of a compressed tar', async () => { + const path = testPath('test.tar.gz'); + await makeTarGz(path, { + 'data/file1.csv': 'a,b,c', + 'data/file2.csv': 'd,e,f', + 'summary.txt': 'summary' + }); + + const listing = await listArchiveContents('test-bucket', path, '', dummyDownloadFn, head()); + + const dirs = listing.entries.filter((e) => e.isDirectory).map((e) => e.key); + const files = listing.entries.filter((e) => !e.isDirectory).map((e) => e.key); + + expect(dirs).toEqual(['data/']); + expect(files).toEqual(['summary.txt']); + }); + }); + + it('rejects RAR listing before downloading the archive', async () => { + const download = vi.fn(); + await expect( + listArchiveContents('test-bucket', 'archive.rar', '', download, head()) + ).rejects.toThrow('Unsupported archive format: archive.rar'); + expect(download).not.toHaveBeenCalled(); + }); + + describe('Nested archives', () => { + it('lists contents of a nested zip inside outer zip', async () => { + const outerPath = makeNestedZip(); + + const listing = await listArchiveContents( + 'test-bucket', + outerPath, + '', + dummyDownloadFn, + head(), + { nestedArchivePath: 'archives/inner.zip' } + ); + + const files = listing.entries.filter((e) => !e.isDirectory).map((e) => e.key); + expect(files).toEqual(['nested.txt']); + }); + }); +}); + +describe('extractArchiveEntry', () => { + beforeEach(() => { + clearArchiveCache(); + }); + + it('rejects RAR extraction before downloading the archive', async () => { + const download = vi.fn(); + await expect( + extractArchiveEntry('test-bucket', 'archive.rar', 'file.txt', download, head()) + ).rejects.toThrow('Unsupported archive format: archive.rar'); + expect(download).not.toHaveBeenCalled(); + }); + + it('extracts a file from a ZIP', async () => { + const path = testPath('extract.zip'); + makeZip(path, { 'hello.txt': 'Hello, World!' }); + + const buf = await extractArchiveEntry( + 'test-bucket', + path, + 'hello.txt', + dummyDownloadFn, + head() + ); + + expect(buf).toBeInstanceOf(Buffer); + expect(buf!.toString('utf-8')).toBe('Hello, World!'); + }); + + it('extracts a file from a TAR', async () => { + const path = testPath('extract.tar'); + await makeTar(path, { 'greeting.txt': 'Hi there' }); + + const buf = await extractArchiveEntry( + 'test-bucket', + path, + 'greeting.txt', + dummyDownloadFn, + head() + ); + + expect(buf!.toString('utf-8')).toBe('Hi there'); + }); + + it('extracts a file from a TAR.GZ', async () => { + const path = testPath('extract.tar.gz'); + await makeTarGz(path, { 'compressed.txt': 'was compressed' }); + + const buf = await extractArchiveEntry( + 'test-bucket', + path, + 'compressed.txt', + dummyDownloadFn, + head() + ); + + expect(buf!.toString('utf-8')).toBe('was compressed'); + }); + + it('returns null for non-existent file', async () => { + const path = testPath('missing.zip'); + makeZip(path, { 'exists.txt': 'content' }); + + const buf = await extractArchiveEntry( + 'test-bucket', + path, + 'not-found.txt', + dummyDownloadFn, + head() + ); + + expect(buf).toBeNull(); + }); + + it('extracts a file from a nested archive', async () => { + const outerPath = makeNestedZip(); + + const buf = await extractArchiveEntry( + 'test-bucket', + outerPath, + 'nested.txt', + dummyDownloadFn, + head(), + { nestedArchivePath: 'archives/inner.zip' } + ); + + expect(buf!.toString('utf-8')).toBe('nested content'); + }); +}); + +describe('Caching', () => { + it('reuses cached archive across listing calls', async () => { + clearArchiveCache(); + const path = testPath('cached.zip'); + makeZip(path, { 'file.txt': 'content' }); + + const downloadSpy = vi.fn(dummyDownloadFn); + + const first = await listArchiveContents('test-bucket', path, '', downloadSpy, head()); + expect(first.entries).toHaveLength(1); + expect(downloadSpy).toHaveBeenCalledTimes(1); + + const second = await listArchiveContents('test-bucket', path, '', downloadSpy, head()); + expect(second.entries).toHaveLength(1); + // downloadFn should NOT be called again — result comes from cache + expect(downloadSpy).toHaveBeenCalledTimes(1); + + clearArchiveCache(); + }); + + it('does not share cached archives between connections', async () => { + clearArchiveCache(); + const archiveStream = (contents: string) => ({ + stream: bufferStream(zipBuffer('file.txt', contents)) + }); + const firstDownload = vi.fn(async () => archiveStream('first connection')); + const secondDownload = vi.fn(async () => archiveStream('second connection')); + + const first = await listArchiveContents( + 'shared-bucket', + 'archive.zip', + '', + firstDownload, + head(), + { connectionId: 'connection-a' } + ); + const second = await listArchiveContents( + 'shared-bucket', + 'archive.zip', + '', + secondDownload, + head(), + { connectionId: 'connection-b' } + ); + + expect(firstDownload).toHaveBeenCalledOnce(); + expect(secondDownload).toHaveBeenCalledOnce(); + expect(first.entries).toHaveLength(1); + expect(second.entries).toHaveLength(1); + clearArchiveCache(); + }); +}); + +describe('Cache invalidation', () => { + beforeEach(() => { + clearArchiveCache(); + storageArchiveCacheEvictionsTotal.reset(); + }); + + async function evictions(reason: string): Promise { + const { values } = await storageArchiveCacheEvictionsTotal.get(); + return values.find((v) => v.labels.reason === reason)?.value ?? 0; + } + + afterEach(() => { + clearArchiveCache(); + }); + + /** An object store holding one archive whose content and ETag can change. */ + function mutableArchive(name: string, content: string) { + const object = { data: zipBuffer(name, content), etag: '"v1"' }; + const download = vi.fn(async () => ({ + stream: bufferStream(object.data), + etag: object.etag + })); + const metadata = vi.fn(async () => ({ size: object.data.length, etag: object.etag })); + return { + download, + metadata, + replace(newName: string, newContent: string, etag: string) { + object.data = zipBuffer(newName, newContent); + object.etag = etag; + } + }; + } + + it('downloads the archive again once its ETag changes', async () => { + const archive = mutableArchive('old.txt', 'old'); + const list = () => + listArchiveContents('test-bucket', 'changing.zip', '', archive.download, archive.metadata); + + expect((await list()).entries.map((e) => e.key)).toEqual(['old.txt']); + expect((await list()).entries.map((e) => e.key)).toEqual(['old.txt']); + expect(archive.download).toHaveBeenCalledOnce(); + + archive.replace('new.txt', 'new', '"v2"'); + + expect((await list()).entries.map((e) => e.key)).toEqual(['new.txt']); + expect(archive.download).toHaveBeenCalledTimes(2); + // The old version is removed as soon as the new one is cached. + expect(cachedFileCount()).toBe(1); + expect(await evictions('stale')).toBe(1); + expect(archiveCacheBytes()).toBe(zipBuffer('new.txt', 'new').length); + }); + + it('refreshes a nested archive when the outer archive changes', async () => { + const outer = (innerFile: string) => { + const zip = new AdmZip(); + zip.addFile('inner.zip', zipBuffer(innerFile, 'content')); + return zip.toBuffer(); + }; + const object = { data: outer('first.txt'), etag: '"v1"' }; + const download = vi.fn(async () => ({ + stream: bufferStream(object.data), + etag: object.etag + })); + const metadata = vi.fn(async () => ({ size: object.data.length, etag: object.etag })); + const list = () => + listArchiveContents('test-bucket', 'outer.zip', '', download, metadata, { + nestedArchivePath: 'inner.zip' + }); + + expect((await list()).entries.map((e) => e.key)).toEqual(['first.txt']); + object.data = outer('second.txt'); + object.etag = '"v2"'; + + expect((await list()).entries.map((e) => e.key)).toEqual(['second.txt']); + expect(download).toHaveBeenCalledTimes(2); + // Outer and nested entry of the new version only. + expect(cachedFileCount()).toBe(2); + }); + + it('does not cache the archive when HEAD fails', async () => { + const path = testPath('head-error.zip'); + makeZip(path, { 'file.txt': 'content' }); + const downloadSpy = vi.fn(dummyDownloadFn); + const metadataFn = vi.fn().mockRejectedValue(new Error('HEAD failed')); + const options = { maxBytes: 1024 * 1024 }; + + const first = await listArchiveContents( + 'test-bucket', + path, + '', + downloadSpy, + metadataFn, + options + ); + expect(first.entries.map((e) => e.key)).toEqual(['file.txt']); + expect(cachedFileCount()).toBe(0); + expect(archiveCacheBytes()).toBe(0); + + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn, options); + expect(downloadSpy).toHaveBeenCalledTimes(2); + expect(cachedFileCount()).toBe(0); + expect(await evictions('single_use')).toBe(2); + expect(await evictions('expired')).toBe(0); + }); + + it('does not cache a download whose ETag differs from the HEAD', async () => { + // HEAD still reports v1, but the GET already returns v2. + const download = vi.fn(async () => ({ + stream: bufferStream(zipBuffer('new.txt', 'new')), + etag: '"v2"' + })); + const list = () => listArchiveContents('test-bucket', 'racing.zip', '', download, head('"v1"')); + + expect((await list()).entries.map((e) => e.key)).toEqual(['new.txt']); + expect(cachedFileCount()).toBe(0); + expect(archiveCacheBytes()).toBe(0); + + await list(); + expect(download).toHaveBeenCalledTimes(2); + }); + + it('does not cache a nested archive extracted from an uncached download', async () => { + const outerPath = makeNestedZip(); + const options = { nestedArchivePath: 'archives/inner.zip', maxBytes: 1024 * 1024 }; + const failingHead = vi.fn().mockRejectedValue(new Error('HEAD failed')); + const mismatchingDownload = vi.fn(async (key) => ({ + ...(await dummyDownloadFn(key)), + etag: '"v2"' + })); + + for (const [download, metadata] of [ + [dummyDownloadFn, failingHead], + [mismatchingDownload, head('"v1"')] + ] as const) { + const listing = await listArchiveContents( + 'test-bucket', + outerPath, + '', + download, + metadata, + options + ); + expect(listing.entries.map((e) => e.key)).toEqual(['nested.txt']); + expect(cachedFileCount()).toBe(0); + expect(archiveCacheBytes()).toBe(0); + } + }); + + it('does not cache an archive whose only version is an epoch Last-Modified', async () => { + const path = testPath('epoch.zip'); + makeZip(path, { 'file.txt': 'content' }); + const downloadSpy = vi.fn(dummyDownloadFn); + const metadataFn = vi.fn(async () => ({ size: 100, lastModified: new Date(0) })); + + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn); + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn); + + expect(downloadSpy).toHaveBeenCalledTimes(2); + expect(cachedFileCount()).toBe(0); + }); + + it('does not cache the archive when HEAD returns no version', async () => { + const path = testPath('no-etag.zip'); + makeZip(path, { 'file.txt': 'content' }); + const downloadSpy = vi.fn(dummyDownloadFn); + const metadataFn = vi.fn(async () => ({ size: 100 })); + + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn); + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn); + + expect(downloadSpy).toHaveBeenCalledTimes(2); + expect(cachedFileCount()).toBe(0); + }); + + it('uses size and last-modified time when there is no ETag', async () => { + const path = testPath('last-modified.zip'); + makeZip(path, { 'file.txt': 'content' }); + const downloadSpy = vi.fn(dummyDownloadFn); + const meta = { size: 100, lastModified: new Date('2026-10-15T10:00:00Z') }; + const metadataFn = vi.fn(async () => meta); + + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn); + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn); + expect(downloadSpy).toHaveBeenCalledOnce(); + + meta.lastModified = new Date('2026-10-15T10:05:00Z'); + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn); + expect(downloadSpy).toHaveBeenCalledTimes(2); + expect(cachedFileCount()).toBe(1); + }); +}); + +// ── Cache limits, deduplication and aborts ────────────────────────────────── + +/** Number of archive files this test worker currently has on disk. */ +function cachedFileCount(): number { + const root = join(tmpdir(), 'cockpit-archive-cache'); + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (!existsSync(root)) return 0; + let count = 0; + // eslint-disable-next-line security/detect-non-literal-fs-filename + for (const name of readdirSync(root)) { + if (!name.startsWith(`${process.pid}.${threadId}-`)) continue; + // eslint-disable-next-line security/detect-non-literal-fs-filename + count += readdirSync(join(root, name)).length; + } + return count; +} + +/** A download whose stream only delivers `data` once `finish()` is called. */ +function controlledDownload(data: Buffer) { + let controller!: ReadableStreamDefaultController; + const state = { cancelled: false }; + const fn = vi.fn(async () => ({ + stream: new ReadableStream({ + start(c) { + controller = c; + }, + cancel() { + state.cancelled = true; + } + }) + })); + return { + fn, + state, + finish() { + controller.enqueue(new Uint8Array(data)); + controller.close(); + } + }; +} + +function zipBuffer(name: string, content: Buffer | string): Buffer { + const zip = new AdmZip(); + zip.addFile(name, typeof content === 'string' ? Buffer.from(content) : content); + return zip.toBuffer(); +} + +describe('Cache limits', () => { + beforeEach(() => { + clearArchiveCache(); + }); + + afterEach(() => { + clearArchiveCache(); + flags.archiveCacheMaxBytes = DEFAULT_CACHE_MAX_BYTES; + }); + + it('enforces the size limit while downloading when HEAD fails', async () => { + const path = testPath('head-fails.zip'); + makeZip(path, { 'random.bin': randomBytes(4096) }); + const metadataFn = vi.fn().mockRejectedValue(new Error('HEAD failed')); + + const listing = await listArchiveContents( + 'test-bucket', + path, + '', + dummyDownloadFn, + metadataFn, + { + maxBytes: 1024 + } + ); + + expect(listing.tooLarge).toBe(true); + expect(archiveCacheBytes()).toBe(0); + expect(cachedFileCount()).toBe(0); + }); + + it('shares one download between concurrent requests', async () => { + const path = testPath('concurrent.zip'); + makeZip(path, { 'file.txt': 'content' }); + const downloadSpy = vi.fn(dummyDownloadFn); + + const [first, second] = await Promise.all([ + listArchiveContents('test-bucket', path, '', downloadSpy, head()), + listArchiveContents('test-bucket', path, '', downloadSpy, head()) + ]); + + expect(downloadSpy).toHaveBeenCalledOnce(); + expect(first.entries).toHaveLength(1); + expect(second.entries).toHaveLength(1); + expect(cachedFileCount()).toBe(1); + }); + + it('aborts the download and removes the partial file when the client goes away', async () => { + const download = controlledDownload(zipBuffer('file.txt', 'content')); + const abort = new AbortController(); + + const listing = listArchiveContents('test-bucket', 'abort.zip', '', download.fn, head(), { + signal: abort.signal + }); + await vi.waitFor(() => expect(download.fn).toHaveBeenCalled()); + abort.abort(); + + await expect(listing).rejects.toThrow(); + await vi.waitFor(() => expect(download.state.cancelled).toBe(true)); + await vi.waitFor(() => expect(cachedFileCount()).toBe(0)); + expect(archiveCacheBytes()).toBe(0); + }); + + it('does not start the download when the client goes away during HEAD', async () => { + let resolveHead!: (meta: { size: number }) => void; + const metadataFn = vi.fn( + () => + new Promise<{ size: number }>((resolve) => { + resolveHead = resolve; + }) + ); + const downloadSpy = vi.fn(dummyDownloadFn); + const abort = new AbortController(); + + const listing = listArchiveContents('test-bucket', 'head.zip', '', downloadSpy, metadataFn, { + signal: abort.signal + }); + await vi.waitFor(() => expect(metadataFn).toHaveBeenCalled()); + abort.abort(); + await expect(listing).rejects.toThrow(); + resolveHead({ size: 100 }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(downloadSpy).not.toHaveBeenCalled(); + expect(cachedFileCount()).toBe(0); + }); + + it('keeps the download running while another request still waits for it', async () => { + const download = controlledDownload(zipBuffer('file.txt', 'content')); + const abort = new AbortController(); + + const aborted = listArchiveContents('test-bucket', 'shared.zip', '', download.fn, head(), { + signal: abort.signal + }); + const waiting = listArchiveContents('test-bucket', 'shared.zip', '', download.fn, head()); + await vi.waitFor(() => expect(download.fn).toHaveBeenCalled()); + abort.abort(); + await expect(aborted).rejects.toThrow(); + + download.finish(); + const listing = await waiting; + + expect(listing.entries.map((e) => e.key)).toEqual(['file.txt']); + expect(download.fn).toHaveBeenCalledOnce(); + expect(download.state.cancelled).toBe(false); + }); + + it('evicts the least recently used archive when the cache is full', async () => { + const paths = ['lru-a.zip', 'lru-b.zip', 'lru-c.zip'].map((name) => { + const path = testPath(name); + makeZip(path, { 'random.bin': randomBytes(1000) }); + return path; + }); + // eslint-disable-next-line security/detect-non-literal-fs-filename + const sizes = paths.map((path) => statSync(path).size); + flags.archiveCacheMaxBytes = sizes[0] + sizes[1] + 10; + // eslint-disable-next-line security/detect-non-literal-fs-filename + const metadataFn = vi.fn(async (key: string) => ({ size: statSync(key).size, etag: '"v1"' })); + const downloadSpy = vi.fn(dummyDownloadFn); + + for (const path of paths) { + await listArchiveContents('test-bucket', path, '', downloadSpy, metadataFn); + } + + expect(archiveCacheBytes()).toBe(sizes[1] + sizes[2]); + expect(cachedFileCount()).toBe(2); + + // The evicted archive is downloaded again, the others come from the cache. + await listArchiveContents('test-bucket', paths[2], '', downloadSpy, metadataFn); + expect(downloadSpy).toHaveBeenCalledTimes(3); + await listArchiveContents('test-bucket', paths[0], '', downloadSpy, metadataFn); + expect(downloadSpy).toHaveBeenCalledTimes(4); + }); + + it('rejects a download when the space is held by archives in use', async () => { + const busy = zipBuffer('random.bin', randomBytes(1000)); + const download = controlledDownload(busy); + flags.archiveCacheMaxBytes = busy.length + 10; + const metadataFn = head('"v1"', busy.length); + + const running = listArchiveContents('test-bucket', 'busy.zip', '', download.fn, metadataFn); + await vi.waitFor(() => expect(download.fn).toHaveBeenCalled()); + + await expect( + listArchiveContents('test-bucket', 'other.zip', '', dummyDownloadFn, metadataFn) + ).rejects.toBeInstanceOf(ArchiveCacheFullError); + + download.finish(); + expect((await running).entries).toHaveLength(1); + }); + + it('counts nested archives towards the cache size', async () => { + const outerPath = makeNestedZip(); + // eslint-disable-next-line security/detect-non-literal-fs-filename + const innerSize = statSync(testPath('inner.zip')).size; + + await listArchiveContents('test-bucket', outerPath, '', dummyDownloadFn, head(), { + nestedArchivePath: 'archives/inner.zip' + }); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + expect(archiveCacheBytes()).toBe(statSync(outerPath).size + innerSize); + expect(cachedFileCount()).toBe(2); + }); +}); diff --git a/src/lib/server/storage/archive.ts b/src/lib/server/storage/archive.ts new file mode 100644 index 00000000..f86c9f68 --- /dev/null +++ b/src/lib/server/storage/archive.ts @@ -0,0 +1,1133 @@ +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync +} from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, extname } from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { threadId } from 'node:worker_threads'; +import { createGunzip } from 'node:zlib'; +import AdmZip from 'adm-zip'; +import * as tar from 'tar-stream'; +import { logger } from '$lib/server/logging'; +import { archiveCacheMaxBytes } from '$lib/server/feature-flags.js'; +import { + storageArchiveCacheBytes, + storageArchiveCacheEvictionsTotal +} from '$lib/server/metrics.js'; +import { objectVersion, type ObjectVersionInfo } from './preview/cache.js'; + +const log = logger.child({ module: 'archive-service' }); + +export interface ArchiveEntry { + key: string; + size: number; + lastModified: Date; + isDirectory: boolean; +} + +export interface ArchiveListing { + entries: ArchiveEntry[]; + hasMore: boolean; + /** When true, the archive exceeded the previewable size limit. */ + tooLarge?: boolean; +} + +/** Thrown when an archive does not fit into the cache because every cached + * archive is in use. Callers should answer with 503. */ +export class ArchiveCacheFullError extends Error { + constructor() { + super('Archive preview cache is full'); + this.name = 'ArchiveCacheFullError'; + } +} + +/** Thrown when an archive exceeds the preview size limit while downloading. */ +class ArchiveTooLargeError extends Error { + constructor(limit: number) { + super(`Archive exceeds the preview size limit of ${limit} bytes`); + this.name = 'ArchiveTooLargeError'; + } +} + +// ── Disk cache ─────────────────────────────────────────────────────────────── +// +// Archives are downloaded to `/cockpit-archive-cache/.-*/` +// and kept for CACHE_TTL. Cache keys include the object's version (ETag), so +// an overwritten archive is downloaded again, and caching the new version +// removes the old one. Without a version (failed HEAD, no ETag), or when the +// GET returned a different ETag than the HEAD, the download is used for one +// request only. The total size is capped at `archiveCacheMaxBytes`: +// space is reserved before a download starts, and the least recently used idle +// archive is evicted to make room. Readers hold a lease on an entry, and leased +// entries are never deleted. Concurrent requests for the same archive share one +// download, which is aborted once every waiting request has gone away. + +const CACHE_TTL = 30 * 60 * 1000; +const CLEANUP_INTERVAL = 60_000; +const CACHE_ROOT = join(tmpdir(), 'cockpit-archive-cache'); + +/** Identifies one cache entry: an outer or nested archive at one version. */ +interface EntryId { + key: string; + /** `connection:bucket:key` of the outer archive, shared by all its versions. */ + objectKey: string; + /** Version of the outer archive; null for a single-use download. */ + version: string | null; +} + +type EvictionReason = 'expired' | 'evicted' | 'missing' | 'stale' | 'single_use'; + +interface CacheEntry { + objectKey: string; + version: string | null; + dir: string; + path: string; + size: number; + expiresAt: number; + lastUsed: number; + leases: number; + /** Set once the entry must not be handed out any more; it is removed with + * this reason as soon as it is no longer leased. */ + retired: EvictionReason | null; + removed: boolean; +} + +interface ArchiveLease { + path: string; + /** The entry is a single-use download that must not be cached further. */ + singleUse: boolean; + release(): void; +} + +interface InflightJob { + promise: Promise; + waiters: number; + controller: AbortController; +} + +const archiveCache = new Map(); +const inflight = new Map(); +let cachedBytes = 0; +let reservedBytes = 0; +let processCacheDir: string | null = null; + +const cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of archiveCache) { + if ((now > entry.expiresAt || entry.retired) && entry.leases === 0) { + removeEntry(key, entry, entry.retired ?? 'expired'); + } + } +}, CLEANUP_INTERVAL); + +if (cleanupTimer.unref) cleanupTimer.unref(); + +function cacheKey(connectionId: string, bucket: string, key: string): string { + return `${connectionId}:${bucket}:${key}`; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** + * Remove cache directories left behind by earlier processes. `/tmp` is an + * emptyDir that survives container restarts, and the restarted process often + * gets the same PID, so directories of this PID and thread are stale as well. + */ +function removeStaleCacheDirs(): void { + // eslint-disable-next-line security/detect-non-literal-fs-filename + for (const name of readdirSync(CACHE_ROOT)) { + const match = /^(\d+)\.(\d+)-/.exec(name); + if (match) { + const pid = Number(match[1]); + const ownProcess = pid === process.pid; + if (ownProcess && Number(match[2]) !== threadId) continue; + if (!ownProcess && isProcessAlive(pid)) continue; + } + try { + rmRecursive(join(CACHE_ROOT, name)); + log.info({ dir: name }, 'removed stale archive cache directory'); + } catch (err) { + log.warn({ err, dir: name }, 'failed to remove stale archive cache directory'); + } + } +} + +function cacheDir(): string { + if (!processCacheDir) { + // eslint-disable-next-line security/detect-non-literal-fs-filename + mkdirSync(CACHE_ROOT, { recursive: true }); + removeStaleCacheDirs(); + processCacheDir = mkdtempSync(join(CACHE_ROOT, `${process.pid}.${threadId}-`)); + } + return processCacheDir; +} + +function updateCacheGauge(): void { + storageArchiveCacheBytes.set(cachedBytes); +} + +function removeEntry(key: string, entry: CacheEntry, reason: EvictionReason) { + if (entry.removed) return; + entry.removed = true; + if (archiveCache.get(key) === entry) archiveCache.delete(key); + cachedBytes -= entry.size; + updateCacheGauge(); + storageArchiveCacheEvictionsTotal.inc({ reason }); + log.debug({ cache_key: key, size: entry.size, reason }, 'removing cached archive'); + try { + rmRecursive(entry.dir); + } catch (err) { + log.warn({ err, cache_key: key }, 'failed to clean up archive temp file'); + } +} + +/** + * Lease a cached archive so it is not deleted while it is being read. A + * retired entry is only handed out to the requests that waited for its fill + * (`ownFill`), e.g. the reader of a single-use download. + */ +function acquire(key: string, ownFill = false): ArchiveLease | null { + const entry = archiveCache.get(key); + if (!entry || (entry.retired && !ownFill)) return null; + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (Date.now() > entry.expiresAt || !existsSync(entry.path)) { + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (entry.leases === 0) removeEntry(key, entry, existsSync(entry.path) ? 'expired' : 'missing'); + return null; + } + entry.leases++; + entry.lastUsed = Date.now(); + let released = false; + return { + path: entry.path, + singleUse: entry.retired === 'single_use', + release() { + if (released) return; + released = true; + entry.leases--; + entry.lastUsed = Date.now(); + if (entry.leases === 0 && (entry.retired || Date.now() > entry.expiresAt)) { + removeEntry(key, entry, entry.retired ?? 'expired'); + } + } + }; +} + +/** + * Reserve `bytes` of cache space, evicting idle archives (least recently used + * first) as needed. Returns a function that gives the reservation back. + */ +function reserve(bytes: number): () => void { + while (cachedBytes + reservedBytes + bytes > archiveCacheMaxBytes) { + let victim: [string, CacheEntry] | null = null; + for (const item of archiveCache) { + if (item[1].leases === 0 && (!victim || item[1].lastUsed < victim[1].lastUsed)) { + victim = item; + } + } + if (!victim) { + log.info( + { + requested_bytes: bytes, + cached_bytes: cachedBytes, + reserved_bytes: reservedBytes, + max_bytes: archiveCacheMaxBytes + }, + 'archive cache full, all cached archives in use' + ); + throw new ArchiveCacheFullError(); + } + removeEntry(victim[0], victim[1], 'evicted'); + } + reservedBytes += bytes; + let returned = false; + return () => { + if (returned) return; + returned = true; + reservedBytes -= bytes; + }; +} + +/** Remove `entry` now, or mark it so that its last reader removes it. */ +function retire(key: string, entry: CacheEntry, reason: EvictionReason): void { + archiveCache.delete(key); + if (entry.leases === 0) removeEntry(key, entry, reason); + else entry.retired = reason; +} + +/** + * Add a freshly written file to the cache. Two fills for the same key can + * overlap (e.g. an aborted job that still finished while a new one started): + * a valid existing entry wins, and an outdated one is retired. Entries for + * other versions of the same archive are retired as well. A `singleUse` entry + * is removed as soon as its readers release it. + */ +function addEntry(id: EntryId, dir: string, path: string, singleUse: boolean): void { + const { key, objectKey, version } = id; + const existing = archiveCache.get(key); + if (existing) { + if ( + !existing.retired && + Date.now() <= existing.expiresAt && + // eslint-disable-next-line security/detect-non-literal-fs-filename + existsSync(existing.path) + ) { + log.debug({ cache_key: key }, 'archive already cached, discarding duplicate download'); + rmRecursive(dir); + return; + } + retire(key, existing, existing.retired ?? 'expired'); + } + + if (!singleUse && version !== null) { + for (const [otherKey, other] of archiveCache) { + if (other.objectKey === objectKey && other.version !== null && other.version !== version) { + log.debug({ cache_key: otherKey }, 'archive changed, retiring cached old version'); + retire(otherKey, other, 'stale'); + } + } + } + + // eslint-disable-next-line security/detect-non-literal-fs-filename + const size = statSync(path).size; + const now = Date.now(); + archiveCache.set(key, { + objectKey, + version, + dir, + path, + size, + expiresAt: now + CACHE_TTL, + lastUsed: now, + leases: 0, + retired: singleUse ? 'single_use' : null, + removed: false + }); + cachedBytes += size; + updateCacheGauge(); +} + +/** Resolve with `promise`, or reject as soon as `signal` aborts. */ +function waitFor(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + +/** + * Return a lease on the cached file for `key`, running `fill` to create it on a + * cache miss. Concurrent callers share one `fill`. The signal passed to `fill` + * aborts once every caller waiting for it has aborted. + */ +async function ensureCached( + id: EntryId, + fill: (signal: AbortSignal) => Promise, + signal?: AbortSignal +): Promise { + const { key } = id; + const cached = acquire(key); + if (cached) { + log.debug({ cache_key: key }, 'archive cache hit'); + return cached; + } + + let job = inflight.get(key); + if (job) { + log.debug({ cache_key: key }, 'joining in-flight archive download'); + } else { + const controller = new AbortController(); + const promise = fill(controller.signal).finally(() => { + if (inflight.get(key) === newJob) inflight.delete(key); + }); + promise.catch(() => {}); + const newJob: InflightJob = { promise, waiters: 0, controller }; + inflight.set(key, newJob); + job = newJob; + } + + job.waiters++; + try { + await waitFor(job.promise, signal); + } finally { + job.waiters--; + if (job.waiters === 0) { + // Nobody waits any more: stop the download (a no-op if it has finished) + // and make sure later requests do not join the aborted job. + if (inflight.get(key) === job) inflight.delete(key); + job.controller.abort(); + } + } + + const lease = acquire(key, true); + // Only possible if the archive was evicted right after it was cached. + if (!lease) throw new ArchiveCacheFullError(); + return lease; +} + +/** A pass-through stream that fails once more than `limit` bytes went through. */ +function byteLimit(limit: number): Transform { + let total = 0; + return new Transform({ + transform(chunk: Buffer, _encoding, callback) { + total += chunk.length; + if (total > limit) callback(new ArchiveTooLargeError(limit)); + else callback(null, chunk); + } + }); +} + +/** + * Write the result of `write` to a new cache entry, reserving `reserveBytes` + * up front. On failure the partial file is removed. The entry is single-use + * when the archive has no version or `write` reports that it must not be cached. + */ +async function fillEntry( + id: EntryId, + ext: string, + reserveBytes: number, + write: (path: string) => Promise<{ singleUse: boolean } | void> +): Promise { + const unreserve = reserve(reserveBytes); + let dir: string | undefined; + try { + dir = mkdtempSync(join(cacheDir(), 'archive-')); + const path = join(dir, `archive${ext}`); + const result = await write(path); + addEntry(id, dir, path, id.version === null || (result?.singleUse ?? false)); + } catch (err) { + try { + if (dir) rmRecursive(dir); + } catch (rmErr) { + log.warn({ err: rmErr, cache_key: id.key }, 'failed to clean up partial archive temp file'); + } + throw err; + } finally { + unreserve(); + } +} + +const ARCHIVE_EXT_PATTERNS = [ + { ext: '.zip', formats: ['zip'] as const }, + { ext: '.tar.gz', formats: ['tar.gz', 'tgz'] as const }, + { ext: '.tar', formats: ['tar'] as const } +]; + +function normalizePath(p: string): string { + return p.replace(/\\/g, '/').replace(/\/+/g, '/'); +} + +function ensureTrailingSlash(p: string): string { + return p.endsWith('/') ? p : p + '/'; +} + +export function getArchiveFormat(key: string): string | null { + const lower = key.toLowerCase(); + for (const { formats } of ARCHIVE_EXT_PATTERNS) { + for (const fmt of formats) { + if (lower.endsWith(`.${fmt}`)) return fmt; + } + } + return null; +} + +// ── ZIP ────────────────────────────────────────────────────────────────────── + +function listZip(tempPath: string, internalPrefix: string, maxBytes?: number): ArchiveListing { + const zip = new AdmZip(tempPath); + const prefix = internalPrefix ? ensureTrailingSlash(normalizePath(internalPrefix)) : ''; + const entries = zip.getEntries(); + + const seenDirs = new Set(); + const result: ArchiveEntry[] = []; + let totalDecompressed = 0; + + for (const entry of entries) { + const entryPath = normalizePath(entry.entryName); + + if (!entryPath.startsWith(prefix)) continue; + + if (!entry.isDirectory) { + if (maxBytes !== undefined) { + totalDecompressed += entry.header.size; + if (totalDecompressed > maxBytes) { + return { entries: [], hasMore: false, tooLarge: true }; + } + } + } + + const relative = entryPath.slice(prefix.length); + if (!relative) continue; + + if (entry.isDirectory) { + if (!relative.includes('/') && !seenDirs.has(relative)) { + seenDirs.add(relative); + result.push({ + key: relative, + size: 0, + lastModified: new Date(entry.header.time), + isDirectory: true + }); + } + } else { + if (relative.includes('/')) { + const dirName = relative.split('/')[0] + '/'; + if (!seenDirs.has(dirName)) { + seenDirs.add(dirName); + result.push({ + key: dirName, + size: 0, + lastModified: new Date(entry.header.time), + isDirectory: true + }); + } + } else { + result.push({ + key: relative, + size: entry.header.size, + lastModified: new Date(entry.header.time), + isDirectory: false + }); + } + } + } + + return { entries: result, hasMore: false }; +} + +function extractZipEntry(tempPath: string, internalPath: string, maxBytes?: number): Buffer | null { + const zip = new AdmZip(tempPath); + const entry = zip.getEntry(internalPath); + if (!entry || entry.isDirectory) return null; + if (maxBytes !== undefined && entry.header.size > maxBytes) return null; + return entry.getData(); +} + +// ── TAR helpers (shared by plain .tar and .tar.gz) ──────────────────────────── + +interface TarListState { + entries: ArchiveEntry[]; + seenDirs: Set; + totalDecompressed: number; + tooLarge: boolean; +} + +function onTarEntry( + header: tar.Headers, + stream: NodeJS.ReadableStream, + next: (err?: Error | null) => void, + prefix: string, + state: TarListState, + maxBytes?: number +): void { + let entryPath = normalizePath(header.name); + + if (header.type === 'directory') { + entryPath = ensureTrailingSlash(entryPath); + } + + if (state.tooLarge) { + stream.resume(); + stream.on('end', next); + return; + } + + if (!entryPath.startsWith(prefix)) { + stream.resume(); + stream.on('end', next); + return; + } + + const relative = entryPath.slice(prefix.length); + if (!relative) { + stream.resume(); + stream.on('end', next); + return; + } + + if (header.type !== 'directory' && header.size && maxBytes !== undefined) { + state.totalDecompressed += header.size; + if (state.totalDecompressed > maxBytes) { + state.tooLarge = true; + stream.resume(); + stream.on('end', next); + return; + } + } + + if (header.type === 'directory') { + if (!relative.includes('/') && !state.seenDirs.has(relative)) { + state.seenDirs.add(relative); + state.entries.push({ + key: relative, + size: 0, + lastModified: new Date(header.mtime?.getTime() ?? 0), + isDirectory: true + }); + } + } else { + if (relative.includes('/')) { + const dirName = relative.split('/')[0] + '/'; + if (!state.seenDirs.has(dirName)) { + state.seenDirs.add(dirName); + state.entries.push({ + key: dirName, + size: 0, + lastModified: new Date(0), + isDirectory: true + }); + } + } else { + state.entries.push({ + key: relative, + size: header.size ?? 0, + lastModified: new Date(header.mtime?.getTime() ?? 0), + isDirectory: false + }); + } + } + + stream.resume(); + stream.on('end', next); +} + +// ── TAR ─────────────────────────────────────────────────────────────────────── + +function listTar( + tempPath: string, + internalPrefix: string, + maxBytes?: number +): Promise { + return new Promise((resolve, reject) => { + const prefix = internalPrefix ? ensureTrailingSlash(normalizePath(internalPrefix)) : ''; + const state: TarListState = { + entries: [], + seenDirs: new Set(), + totalDecompressed: 0, + tooLarge: false + }; + + const extract = tar.extract(); + + extract.on( + 'entry', + (header: tar.Headers, stream: NodeJS.ReadableStream, next: (err?: Error | null) => void) => { + onTarEntry(header, stream, next, prefix, state, maxBytes); + } + ); + + extract.on('finish', () => { + if (state.tooLarge) { + resolve({ entries: [], hasMore: false, tooLarge: true }); + } else { + resolve({ entries: state.entries, hasMore: false }); + } + }); + extract.on('error', reject); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + createReadStream(tempPath).pipe(extract); + }); +} + +function extractTarEntry( + tempPath: string, + internalPath: string, + maxBytes?: number +): Promise { + return new Promise((resolve, reject) => { + const normalized = normalizePath(internalPath); + let found: Buffer | null = null; + + const extract = tar.extract(); + + extract.on( + 'entry', + (header: tar.Headers, stream: NodeJS.ReadableStream, next: (err?: Error | null) => void) => { + const entryPath = normalizePath(header.name); + if (header.type === 'file' && entryPath === normalized && !found) { + if (maxBytes !== undefined && (header.size ?? 0) > maxBytes) { + stream.resume(); + stream.on('end', next); + return; + } + const chunks: Buffer[] = []; + let size = 0; + let tooLarge = false; + stream.on('data', (chunk: Buffer) => { + size += chunk.length; + if (maxBytes !== undefined && size > maxBytes) { + tooLarge = true; + return; + } + if (!tooLarge) chunks.push(chunk); + }); + stream.on('end', () => { + if (!tooLarge) found = Buffer.concat(chunks); + next(); + }); + } else { + stream.resume(); + stream.on('end', next); + } + } + ); + + extract.on('finish', () => resolve(found)); + extract.on('error', reject); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + createReadStream(tempPath).pipe(extract); + }); +} + +// ── TAR.GZ / TGZ ───────────────────────────────────────────────────────────── + +function listTarGz( + tempPath: string, + internalPrefix: string, + maxBytes?: number +): Promise { + return new Promise((resolve, reject) => { + const prefix = internalPrefix ? ensureTrailingSlash(normalizePath(internalPrefix)) : ''; + const state: TarListState = { + entries: [], + seenDirs: new Set(), + totalDecompressed: 0, + tooLarge: false + }; + + const extract = tar.extract(); + const gunzip = createGunzip(); + + extract.on( + 'entry', + (header: tar.Headers, stream: NodeJS.ReadableStream, next: (err?: Error | null) => void) => { + onTarEntry(header, stream, next, prefix, state, maxBytes); + } + ); + + extract.on('finish', () => { + if (state.tooLarge) { + resolve({ entries: [], hasMore: false, tooLarge: true }); + } else { + resolve({ entries: state.entries, hasMore: false }); + } + }); + extract.on('error', reject); + gunzip.on('error', reject); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + createReadStream(tempPath).pipe(gunzip).pipe(extract); + }); +} + +function extractTarGzEntry( + tempPath: string, + internalPath: string, + maxBytes?: number +): Promise { + return new Promise((resolve, reject) => { + const normalized = normalizePath(internalPath); + let found: Buffer | null = null; + + const extract = tar.extract(); + const gunzip = createGunzip(); + + extract.on( + 'entry', + (header: tar.Headers, stream: NodeJS.ReadableStream, next: (err?: Error | null) => void) => { + const entryPath = normalizePath(header.name); + if (header.type === 'file' && entryPath === normalized && !found) { + if (maxBytes !== undefined && (header.size ?? 0) > maxBytes) { + stream.resume(); + stream.on('end', next); + return; + } + const chunks: Buffer[] = []; + let size = 0; + let tooLarge = false; + stream.on('data', (chunk: Buffer) => { + size += chunk.length; + if (maxBytes !== undefined && size > maxBytes) { + tooLarge = true; + return; + } + if (!tooLarge) chunks.push(chunk); + }); + stream.on('end', () => { + if (!tooLarge) found = Buffer.concat(chunks); + next(); + }); + } else { + stream.resume(); + stream.on('end', next); + } + } + ); + + extract.on('finish', () => resolve(found)); + extract.on('error', reject); + gunzip.on('error', reject); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + createReadStream(tempPath).pipe(gunzip).pipe(extract); + }); +} + +function rmRecursive(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +export type ArchiveDownloadFn = (key: string) => Promise<{ stream: ReadableStream; etag?: string }>; +export type ArchiveMetadataFn = (key: string) => Promise; + +export interface ArchiveOptions { + /** Path of an archive inside the outer archive to operate on instead. */ + nestedArchivePath?: string; + /** Preview size limit for the downloaded archive, the total decompressed + * listing size and a single extracted entry. */ + maxBytes?: number; + /** Storage connection the archive belongs to. Part of the cache key. */ + connectionId?: string; + /** Stops waiting for the archive, and stops its download once no other + * request waits for it either. */ + signal?: AbortSignal; +} + +async function extractEntry( + format: string, + tempPath: string, + internalPath: string, + maxBytes?: number +): Promise { + switch (format) { + case 'zip': + return extractZipEntry(tempPath, internalPath, maxBytes); + case 'tar': + return extractTarEntry(tempPath, internalPath, maxBytes); + case 'tar.gz': + case 'tgz': + return extractTarGzEntry(tempPath, internalPath, maxBytes); + default: + return null; + } +} + +interface ArchiveHead { + /** Entry of the outer archive at its current version. */ + outer: EntryId; + /** Size from HEAD, if it succeeded. */ + size?: number; + /** ETag from HEAD, if any. */ + etag?: string; +} + +/** + * HEAD the archive to find its current version. When that fails or yields no + * version, the archive gets a unique single-use key, so a cached copy is never + * served without knowing it is current. + */ +async function headArchive( + bucket: string, + key: string, + metadataFn: ArchiveMetadataFn, + connectionId: string, + signal?: AbortSignal +): Promise { + let meta: ObjectVersionInfo | undefined; + try { + // HEAD cannot be aborted, but the request stops waiting for it. + meta = await waitFor(metadataFn(key), signal); + } catch (err) { + signal?.throwIfAborted(); + log.debug( + { err, bucket, key }, + 'archive metadata unavailable, downloading without caching and checking size while downloading' + ); + } + + const version = (meta && objectVersion(meta)) ?? null; + if (meta && version === null) { + log.debug({ bucket, key }, 'archive has no ETag, downloading without caching'); + } + const objectKey = cacheKey(connectionId, bucket, key); + return { + outer: { + key: `${objectKey}@${version ?? `single-use-${randomUUID()}`}`, + objectKey, + version + }, + size: meta?.size, + etag: meta?.etag + }; +} + +/** + * Lease the outer archive from the cache, downloading it on a cache miss. + * The download is refused when the object is larger than the limit, checked + * via HEAD first and enforced while streaming in any case. + */ +function leaseOuterArchive( + bucket: string, + key: string, + downloadFn: ArchiveDownloadFn, + head: ArchiveHead, + maxBytes: number | undefined, + signal?: AbortSignal +): Promise { + const limit = maxBytes ?? archiveCacheMaxBytes; + const { outer, size, etag } = head; + + return ensureCached( + outer, + async (jobSignal) => { + if (size !== undefined && size > limit) { + log.info( + { bucket, key, compressed_size: size, max_bytes: limit }, + 'archive compressed size exceeds limit' + ); + throw new ArchiveTooLargeError(limit); + } + + log.info({ bucket, key }, 'downloading archive'); + await fillEntry( + outer, + extname(key) || '.bin', + Math.min(size ?? limit, limit), + async (path) => { + const download = await downloadFn(key); + await pipeline( + Readable.fromWeb(download.stream as import('stream/web').ReadableStream), + byteLimit(limit), + // eslint-disable-next-line security/detect-non-literal-fs-filename + createWriteStream(path), + { signal: jobSignal } + ); + // Overwritten between HEAD and GET: these bytes are not the version + // the cache key names, so serve them to this request only. + const changed = + etag !== undefined && download.etag !== undefined && download.etag !== etag; + if (changed) { + log.info( + { bucket, key, head_etag: etag, get_etag: download.etag }, + 'archive changed while downloading, not caching it' + ); + } + return { singleUse: changed }; + } + ); + }, + signal + ); +} + +/** + * Lease the archive to operate on. For nested archives, the outer archive is + * leased first and the nested one is extracted from it into its own cache entry. + */ +async function leaseArchive( + bucket: string, + key: string, + downloadFn: ArchiveDownloadFn, + metadataFn: ArchiveMetadataFn, + options: ArchiveOptions +): Promise { + const { nestedArchivePath, maxBytes, connectionId = '', signal } = options; + const head = await headArchive(bucket, key, metadataFn, connectionId, signal); + if (!nestedArchivePath) { + return leaseOuterArchive(bucket, key, downloadFn, head, maxBytes, signal); + } + + const nested: EntryId = { ...head.outer, key: `${head.outer.key}!/${nestedArchivePath}` }; + return ensureCached( + nested, + async (jobSignal) => { + const outer = await leaseOuterArchive(bucket, key, downloadFn, head, maxBytes, jobSignal); + try { + log.info( + { bucket, key, nested_archive_path: nestedArchivePath }, + 'extracting nested archive' + ); + const format = getArchiveFormat(key); + if (!format) throw new Error(`Unsupported archive format: ${key}`); + + const nestedData = await extractEntry( + format, + outer.path, + normalizePath(nestedArchivePath), + maxBytes + ); + if (!nestedData) { + throw new Error(`Nested archive "${nestedArchivePath}" not found in ${key}`); + } + jobSignal.throwIfAborted(); + + await fillEntry( + nested, + extname(nestedArchivePath) || '.bin', + nestedData.length, + async (path) => { + // eslint-disable-next-line security/detect-non-literal-fs-filename + await writeFile(path, nestedData, { signal: jobSignal }); + // Extracted from an outer download that is not cached either. + return { singleUse: outer.singleUse }; + } + ); + } finally { + outer.release(); + } + }, + signal + ); +} + +/** + * List the contents of an archive at a given internal prefix. + * Downloads the archive once and caches it on local disk until it changes + * (ETag), for at most 30 minutes. + * Supports nested archives via `options.nestedArchivePath`. + */ +export async function listArchiveContents( + bucket: string, + key: string, + internalPrefix: string, + downloadFn: ArchiveDownloadFn, + metadataFn: ArchiveMetadataFn, + options: ArchiveOptions = {} +): Promise { + const { nestedArchivePath, maxBytes } = options; + const effectiveKey = nestedArchivePath ? `${key}!/${nestedArchivePath}` : key; + const format = getArchiveFormat(effectiveKey); + if (!format) throw new Error(`Unsupported archive format: ${key}`); + + let lease: ArchiveLease; + try { + lease = await leaseArchive(bucket, key, downloadFn, metadataFn, options); + } catch (err) { + if (err instanceof ArchiveTooLargeError) return { entries: [], hasMore: false, tooLarge: true }; + throw err; + } + + try { + log.debug( + { + bucket, + key, + internal_prefix: internalPrefix, + nested_archive_path: nestedArchivePath, + format + }, + 'listing archive contents' + ); + + switch (format) { + case 'zip': + return listZip(lease.path, internalPrefix, maxBytes); + case 'tar': + return await listTar(lease.path, internalPrefix, maxBytes); + case 'tar.gz': + case 'tgz': + return await listTarGz(lease.path, internalPrefix, maxBytes); + default: + throw new Error(`Unsupported archive format: ${format}`); + } + } finally { + lease.release(); + } +} + +/** + * Extract a single file from an archive and return its content as a Buffer. + * Returns null if the file does not exist or exceeds the size limit. + * Supports nested archives via `options.nestedArchivePath`. + */ +export async function extractArchiveEntry( + bucket: string, + key: string, + internalPath: string, + downloadFn: ArchiveDownloadFn, + metadataFn: ArchiveMetadataFn, + options: ArchiveOptions = {} +): Promise { + const { nestedArchivePath, maxBytes } = options; + const effectiveKey = nestedArchivePath ? `${key}!/${nestedArchivePath}` : key; + const format = getArchiveFormat(effectiveKey); + if (!format) throw new Error(`Unsupported archive format: ${key}`); + + let lease: ArchiveLease; + try { + lease = await leaseArchive(bucket, key, downloadFn, metadataFn, options); + } catch (err) { + if (err instanceof ArchiveTooLargeError) { + log.info({ bucket, key }, 'archive exceeds size limit, extraction aborted'); + return null; + } + throw err; + } + + let data: Buffer | null; + try { + log.debug( + { bucket, key, internal_path: internalPath, nested_archive_path: nestedArchivePath, format }, + 'extracting archive entry' + ); + data = await extractEntry(format, lease.path, normalizePath(internalPath), maxBytes); + } finally { + lease.release(); + } + + // Post-extraction size check + if (data && maxBytes !== undefined && data.length > maxBytes) { + log.info( + { + bucket, + key, + internal_path: internalPath, + extracted_size: data.length, + max_bytes: maxBytes + }, + 'extracted entry size exceeds limit' + ); + return null; + } + + return data; +} + +/** + * Abort in-flight downloads and remove all cached archive temp files. + * Useful for testing. + */ +export function clearArchiveCache(): void { + for (const job of inflight.values()) job.controller.abort(); + inflight.clear(); + for (const [key, entry] of archiveCache) removeEntry(key, entry, 'evicted'); +} + +/** Bytes currently held by the archive cache. Useful for testing. */ +export function archiveCacheBytes(): number { + return cachedBytes; +} diff --git a/src/lib/server/storage/connection.test.ts b/src/lib/server/storage/connection.test.ts index 488ad860..fc77eade 100644 --- a/src/lib/server/storage/connection.test.ts +++ b/src/lib/server/storage/connection.test.ts @@ -1,25 +1,113 @@ -import { describe, expect, it } from 'vitest'; -import { requireStorageConfig } from './connection.js'; -import type { S3ConnectionConfig } from './types.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -function locals(storageConfig: S3ConnectionConfig | null): App.Locals { - return { storageConfig } as App.Locals; +const { select, update } = vi.hoisted(() => ({ + select: vi.fn(), + update: vi.fn() +})); + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); +vi.mock('$lib/server/db.js', () => ({ db: { select, update } })); +vi.mock('./encryption-key.js', () => ({ storageEncryptionKey: () => Buffer.alloc(32, 1) })); + +import { encrypt } from './encryption.js'; +import { getConnectionFromHeader } from './connection.js'; +import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; + +const connectionId = '0b9f3c1e-8d4a-4f6b-9c2e-1a2b3c4d5e6f'; +const key = Buffer.alloc(32, 1); + +const payload = { + host: 'storage.example.test', + port: 9000, + tls: { verification: 'Full' }, + accessStyle: 'Path', + region: { name: 'eu-central-1' }, + credentials: { accessKey: 'access', secretKey: 'secret' } +}; + +function request(id: string | null = connectionId): Request { + const headers = new Headers(); + if (id !== null) headers.set(STORAGE_CONNECTION_ID_HEADER, id); + return new Request('http://localhost/api/storage/list', { headers }); +} + +function selectRows(rows: unknown[]) { + select.mockReturnValue({ from: () => ({ where: () => ({ limit: () => rows }) }) }); +} + +function row(overrides: Record = {}) { + return { + id: connectionId, + encryptedPayload: encrypt(JSON.stringify(payload), key), + additionalBuckets: ['extra'], + updatedAt: new Date(), + ...overrides + }; } -describe('requireStorageConfig', () => { - it('returns the parsed connection', () => { - const config: S3ConnectionConfig = { +describe('getConnectionFromHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + update.mockReturnValue({ + set: () => ({ where: () => Promise.resolve() }) + }); + }); + + it('returns null without a connection header', async () => { + await expect(getConnectionFromHeader(request(null), 'user-id')).resolves.toBeNull(); + expect(select).not.toHaveBeenCalled(); + }); + + it('rejects a connection ID that is not a UUID', async () => { + await expect(getConnectionFromHeader(request('nope'), 'user-id')).rejects.toMatchObject({ + status: 400 + }); + }); + + it('rejects an unknown connection', async () => { + selectRows([]); + await expect(getConnectionFromHeader(request(), 'user-id')).rejects.toMatchObject({ + status: 401 + }); + }); + + it('returns the decrypted connection config', async () => { + selectRows([row()]); + await expect(getConnectionFromHeader(request(), 'user-id')).resolves.toEqual({ type: 's3', - host: 's3.example.com', + host: 'storage.example.test', + port: 9000, + tls: { verification: 'Full' }, accessStyle: 'Path', - region: { name: 'us-east-1' } - }; - expect(requireStorageConfig(locals(config))).toBe(config); + region: { name: 'eu-central-1' }, + credentials: { accessKey: 'access', secretKey: 'secret' }, + additionalBuckets: ['extra'] + }); + }); + + it('rejects a payload that does not match the stored schema', async () => { + const invalid = { ...payload, accessStyle: undefined }; + selectRows([row({ encryptedPayload: encrypt(JSON.stringify(invalid), key) })]); + await expect(getConnectionFromHeader(request(), 'user-id')).rejects.toMatchObject({ + status: 500 + }); + }); + + it('falls back to no additional buckets when the stored list is invalid', async () => { + selectRows([row({ additionalBuckets: [42] })]); + const config = await getConnectionFromHeader(request(), 'user-id'); + expect(config?.additionalBuckets).toEqual([]); + }); + + it('does not refresh a recently updated connection', async () => { + selectRows([row({ updatedAt: new Date(Date.now() - 60_000) })]); + await getConnectionFromHeader(request(), 'user-id'); + expect(update).not.toHaveBeenCalled(); }); - it('throws 401 when no connection was parsed', () => { - expect(() => requireStorageConfig(locals(null))).toThrow( - expect.objectContaining({ status: 401 }) - ); + it('refreshes updated_at when the connection was last used a while ago', async () => { + selectRows([row({ updatedAt: new Date(Date.now() - 10 * 60_000) })]); + await getConnectionFromHeader(request(), 'user-id'); + expect(update).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/server/storage/connection.ts b/src/lib/server/storage/connection.ts index 4341e265..bebcc45c 100644 --- a/src/lib/server/storage/connection.ts +++ b/src/lib/server/storage/connection.ts @@ -1,70 +1,100 @@ import { error } from '@sveltejs/kit'; -import { StorageConnectionSchema } from '$lib/storage/schemas.js'; -import { STORAGE_CONNECTION_HEADER } from '$lib/storage/connection-storage.js'; +import { z } from 'zod'; +import { eq, and } from 'drizzle-orm'; +import { db } from '$lib/server/db.js'; +import { userStorageConnections } from '$lib/server/schema.js'; +import { decrypt } from './encryption.js'; +import { storageEncryptionKey } from './encryption-key.js'; import type { S3ConnectionConfig } from './types.js'; import { logger } from '$lib/server/logging'; +import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; +import { StoredStorageConnectionSchema } from '$lib/storage/schemas.js'; const log = logger.child({ module: 'storage-connection' }); +/** Minimum age of `updated_at` before a request refreshes it again. */ +const LAST_USED_TOUCH_INTERVAL_MS = 5 * 60_000; + +const AdditionalBucketsSchema = z.array(z.string()); + /** - * Parse and validate a base64-encoded JSON connection payload from a request header value. - * Throws a 400 HTTP error if the payload is malformed or fails schema validation. - * Throws a 400 HTTP error if the connection type is not 's3'. + * Look up a storage connection by UUID and authenticated user ID, decrypt the + * payload, and return the {@link S3ConnectionConfig}. + * + * Also refreshes `updated_at` on the connection row (fire-and-forget) so that + * connection lists are ordered most-recently-used first. + * The write is skipped while `updated_at` is younger than + * {@link LAST_USED_TOUCH_INTERVAL_MS}, so not every storage request hits the database. + * + * Returns `null` if the header is absent. + * Throws 401 if the connection is not found or does not belong to the user. + * Throws 500 if decryption fails or the payload does not match the stored schema. */ -function parseConnectionPayload(raw: string): S3ConnectionConfig { - let data: unknown; +export async function getConnectionFromHeader( + request: Request, + userId: string +): Promise { + const connectionId = request.headers.get(STORAGE_CONNECTION_ID_HEADER); + if (!connectionId) return null; + if (!z.uuid().safeParse(connectionId).success) throw error(400, 'Invalid storage connection ID'); + + const rows = await db + .select() + .from(userStorageConnections) + .where( + and(eq(userStorageConnections.id, connectionId), eq(userStorageConnections.userId, userId)) + ) + .limit(1); + + if (rows.length === 0) { + log.warn({ connection_id: connectionId }, 'storage connection not found or unauthorised'); + throw error(401, 'Storage connection not found'); + } + + const row = rows[0]; + + let payload; try { - data = JSON.parse(atob(raw)); - } catch { - throw error(400, 'Invalid storage connection header'); + payload = StoredStorageConnectionSchema.parse( + JSON.parse(decrypt(row.encryptedPayload, storageEncryptionKey())) + ); + } catch (err) { + log.error( + { err, connection_id: connectionId }, + 'failed to decrypt or parse storage connection payload' + ); + throw error(500, 'Failed to decrypt storage connection'); } - const parsed = StorageConnectionSchema.safeParse(data); - if (!parsed.success) { - log.debug({ issues: parsed.error.issues }, 'storage connection header validation failed'); - throw error(400, 'Invalid storage connection configuration'); + const additionalBuckets = AdditionalBucketsSchema.safeParse(row.additionalBuckets); + if (!additionalBuckets.success) { + log.warn({ connection_id: connectionId }, 'invalid additional buckets on storage connection'); } - if (parsed.data.type !== 's3') { - throw error(400, 'Storage backend not supported'); + // Update updated_at asynchronously — do not block the request on this. + if (Date.now() - row.updatedAt.getTime() > LAST_USED_TOUCH_INTERVAL_MS) { + log.debug({ connection_id: connectionId }, 'refreshing storage connection updated_at'); + db.update(userStorageConnections) + .set({ updatedAt: new Date() }) + .where(eq(userStorageConnections.id, connectionId)) + .catch((err) => + log.warn({ err, connection_id: connectionId }, 'failed to update connection updated_at') + ); } - const { host, port, tls, accessStyle, region, credentials } = parsed.data; const resolvedCredentials = - credentials.accessKey && credentials.secretKey ? credentials : undefined; + payload.credentials?.accessKey && payload.credentials?.secretKey + ? payload.credentials + : undefined; return { type: 's3', - host, - port, - tls, - accessStyle, - region, - credentials: resolvedCredentials + host: payload.host, + port: payload.port, + tls: payload.tls, + accessStyle: payload.accessStyle, + region: payload.region, + credentials: resolvedCredentials, + additionalBuckets: additionalBuckets.success ? additionalBuckets.data : [] }; } - -/** - * Extract the storage connection config from the `X-Storage-Connection` request header. - * Returns `null` if the header is absent. Throws 400 on malformed or invalid payloads. - * - * Used by the `handleStorageConnection` middleware in `hooks.server.ts` to populate - * `event.locals.storageConfig` before any storage API handler runs. - */ -export function getConnectionFromHeader(request: Request): S3ConnectionConfig | null { - const header = request.headers.get(STORAGE_CONNECTION_HEADER); - if (!header) return null; - return parseConnectionPayload(header); -} - -/** - * Return the storage connection parsed by `handleStorageConnection`. - * The middleware already rejects storage API requests without a connection, so this only - * throws if a handler is reached by a route the middleware does not cover. - */ -export function requireStorageConfig(locals: App.Locals): S3ConnectionConfig { - if (!locals.storageConfig) { - throw error(401, 'No storage connection configured'); - } - return locals.storageConfig; -} diff --git a/src/lib/server/storage/connections-db.test.ts b/src/lib/server/storage/connections-db.test.ts new file mode 100644 index 00000000..b874882d --- /dev/null +++ b/src/lib/server/storage/connections-db.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { select, insert, remove, transaction } = vi.hoisted(() => ({ + select: vi.fn(), + insert: vi.fn(), + remove: vi.fn(), + transaction: vi.fn() +})); + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); +vi.mock('$lib/server/db.js', () => ({ + db: { select, insert, delete: remove, transaction } +})); +vi.mock('./encryption-key.js', () => ({ storageEncryptionKey: () => Buffer.alloc(32, 1) })); + +import { + deleteConnection, + getConnectionForUser, + isConnectionNameConflict, + saveConnection +} from './connections-db.js'; + +const config = { + type: 's3' as const, + host: 'storage.example.test', + port: 9000, + tls: { verification: 'Full' as const }, + accessStyle: 'Path' as const, + region: { name: 'eu-central-1' }, + credentials: { accessKey: 'access', secretKey: 'secret' } +}; + +function selectRows(rows: unknown[]) { + select.mockReturnValue({ from: () => ({ where: () => ({ limit: () => rows }) }) }); +} + +describe('storage connections database', () => { + beforeEach(() => { + vi.clearAllMocks(); + transaction.mockImplementation(async (callback) => callback({ delete: remove })); + }); + + it('reuses a connection with matching credentials', async () => { + selectRows([{ id: 'existing-id' }]); + + await expect(saveConnection('user-id', config)).resolves.toBe('existing-id'); + expect(insert).not.toHaveBeenCalled(); + }); + + it('encrypts and inserts a new connection', async () => { + selectRows([]); + const returning = vi.fn().mockResolvedValue([{ id: 'new-id' }]); + const values = vi.fn(() => ({ returning })); + insert.mockReturnValue({ values }); + + await expect(saveConnection('user-id', config)).resolves.toBe('new-id'); + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-id', + name: 'storage.example.test', + encryptedPayload: expect.any(String), + hash: expect.any(String) + }) + ); + }); + + it('retries with a numeric suffix after a name conflict', async () => { + selectRows([]); + const returning = vi + .fn() + .mockRejectedValueOnce(new Error('user_storage_connections_user_id_name_unique')) + .mockResolvedValue([{ id: 'new-id' }]); + const values = vi.fn(() => ({ returning })); + insert.mockReturnValue({ values }); + + await expect(saveConnection('user-id', config)).resolves.toBe('new-id'); + expect(values).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'storage.example.test 2' }) + ); + }); + + it('recognises name conflicts wrapped in a driver error cause', () => { + const wrapped = new Error('Failed query: update ...', { + cause: new Error( + 'duplicate key value violates unique constraint "user_storage_connections_user_id_name_unique"' + ) + }); + expect(isConnectionNameConflict(wrapped)).toBe(true); + expect(isConnectionNameConflict(new Error('connection refused'))).toBe(false); + }); + + it('reuses a concurrently created matching connection after a duplicate error', async () => { + selectRows([]); + const returning = vi + .fn() + .mockRejectedValueOnce(new Error('duplicate key value violates unique constraint')); + const values = vi.fn(() => ({ returning })); + insert.mockReturnValue({ values }); + select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: () => [] }) }) }); + select.mockReturnValueOnce({ + from: () => ({ where: () => ({ limit: () => [{ id: 'existing-id' }] }) }) + }); + + await expect(saveConnection('user-id', config)).resolves.toBe('existing-id'); + }); + + it('returns null for a connection not owned by the user', async () => { + selectRows([]); + await expect(getConnectionForUser('user-id', 'connection-id')).resolves.toBeNull(); + }); + + it('decrypts a stored connection configuration', async () => { + const { encrypt } = await import('./encryption.js'); + selectRows([ + { + encryptedPayload: encrypt( + JSON.stringify({ ...config, credentials: config.credentials }), + Buffer.alloc(32, 1) + ) + } + ]); + + await expect(getConnectionForUser('user-id', 'connection-id')).resolves.toEqual(config); + }); + + it('atomically deletes only the owned connection history before the connection', async () => { + const where = vi.fn().mockResolvedValue(undefined); + remove.mockReturnValue({ where }); + + await deleteConnection('user-id', 'connection-id'); + expect(transaction).toHaveBeenCalledOnce(); + expect(where).toHaveBeenCalledTimes(2); + expect(where.mock.calls[0]?.[0]).toBeDefined(); + expect(where.mock.calls[1]?.[0]).toBeDefined(); + }); +}); diff --git a/src/lib/server/storage/connections-db.ts b/src/lib/server/storage/connections-db.ts new file mode 100644 index 00000000..01c5af23 --- /dev/null +++ b/src/lib/server/storage/connections-db.ts @@ -0,0 +1,166 @@ +import { eq, and } from 'drizzle-orm'; +import { db } from '$lib/server/db.js'; +import { storageDownloadManifests, userStorageConnections } from '$lib/server/schema.js'; +import { encrypt, decrypt, fingerprint } from './encryption.js'; +import { storageEncryptionKey } from './encryption-key.js'; +import { logger } from '$lib/server/logging'; +import type { S3ConnectionConfig } from './types.js'; +import { StoredStorageConnectionSchema } from '$lib/storage/schemas.js'; + +const log = logger.child({ module: 'connections-db' }); + +/** + * Save an S3 connection for a user. If a connection with identical credentials + * already exists (same fingerprint), return its existing ID without inserting. + * The connection name is auto-generated from the hostname. + * Returns the connection ID. + */ +export async function saveConnection(userId: string, config: S3ConnectionConfig): Promise { + const key = storageEncryptionKey(); + + const fp = fingerprint( + { + endpoint: config.host, + port: config.port, + tls: config.tls, + accessStyle: config.accessStyle, + region: config.region.name, + accessKeyId: config.credentials?.accessKey || '', + secretAccessKey: config.credentials?.secretKey || '' + }, + key + ); + + // Reuse existing connection if credentials are identical. + const existing = await db + .select({ id: userStorageConnections.id }) + .from(userStorageConnections) + .where(and(eq(userStorageConnections.userId, userId), eq(userStorageConnections.hash, fp))) + .limit(1); + + if (existing.length > 0) { + log.debug({ connection_id: existing[0].id }, 'reusing existing storage connection'); + return existing[0].id; + } + + const payload = { + host: config.host, + port: config.port, + tls: config.tls, + accessStyle: config.accessStyle, + region: config.region, + credentials: config.credentials + }; + const encryptedPayload = encrypt(JSON.stringify(payload), key); + + // Auto-generate name from hostname. + let baseName = config.host; + if (!baseName) baseName = 'S3'; + + // Insert, appending a numeric suffix on name collisions. + let name = baseName; + for (let suffix = 2; suffix <= 99; suffix++) { + try { + const [inserted] = await db + .insert(userStorageConnections) + .values({ userId, name, encryptedPayload, hash: fp, additionalBuckets: [] }) + .returning({ id: userStorageConnections.id }); + log.info({ connection_id: inserted.id }, 'storage connection saved'); + return inserted.id; + } catch (err: unknown) { + if (isConnectionNameConflict(err)) { + // A concurrent request may have inserted the same connection after our + // initial lookup. Reuse it rather than surfacing a database error. + const duplicate = await db + .select({ id: userStorageConnections.id }) + .from(userStorageConnections) + .where( + and(eq(userStorageConnections.userId, userId), eq(userStorageConnections.hash, fp)) + ) + .limit(1); + if (duplicate.length > 0) return duplicate[0].id; + name = `${baseName} ${suffix}`; + continue; + } + throw err; + } + } + + throw new Error('Could not generate a unique connection name'); +} + +/** + * Retrieve and decrypt the S3 config for a specific connection belonging to a user. + * Returns null if the connection does not exist or does not belong to the user. + */ +export async function getConnectionForUser( + userId: string, + connectionId: string +): Promise { + const rows = await db + .select() + .from(userStorageConnections) + .where( + and(eq(userStorageConnections.id, connectionId), eq(userStorageConnections.userId, userId)) + ) + .limit(1); + + if (rows.length === 0) return null; + + try { + const payload = StoredStorageConnectionSchema.parse( + JSON.parse(decrypt(rows[0].encryptedPayload, storageEncryptionKey())) + ); + return { + type: 's3', + host: payload.host, + port: payload.port, + tls: payload.tls, + accessStyle: payload.accessStyle, + region: payload.region, + credentials: payload.credentials?.accessKey ? payload.credentials : undefined + }; + } catch (err) { + log.error({ err, connection_id: connectionId }, 'failed to decrypt storage connection'); + return null; + } +} + +/** + * Delete a storage connection belonging to a user. + */ +export async function deleteConnection(userId: string, connectionId: string): Promise { + await db.transaction(async (tx) => { + await tx + .delete(storageDownloadManifests) + .where( + and( + eq(storageDownloadManifests.connectionId, connectionId), + eq(storageDownloadManifests.userId, userId) + ) + ); + await tx + .delete(userStorageConnections) + .where( + and(eq(userStorageConnections.id, connectionId), eq(userStorageConnections.userId, userId)) + ); + }); + log.info({ connection_id: connectionId }, 'storage connection deleted'); +} + +/** + * True when a database error is a violation of the per-user unique + * connection name constraint (or another unique constraint on the table). + */ +export function isConnectionNameConflict(err: unknown): boolean { + const msg = [ + err instanceof Error ? err.message : String(err), + err && typeof err === 'object' && 'cause' in err + ? String((err as { cause: unknown }).cause) + : '' + ].join(' '); + return ( + msg.includes('user_storage_connections_user_id_name_unique') || + /unique constraint|duplicate key/i.test(msg) + ); +} diff --git a/src/lib/server/storage/content-disposition.test.ts b/src/lib/server/storage/content-disposition.test.ts new file mode 100644 index 00000000..82ac5d0a --- /dev/null +++ b/src/lib/server/storage/content-disposition.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { contentDispositionFilename } from './content-disposition.js'; + +describe('download filename headers', () => { + it('escapes disposition delimiters and encodes Unicode and RFC 5987 reserved characters', () => { + const header = contentDispositionFilename('中😀a";x="y\\\r\n\'()*.txt'); + const response = new Response(null, { headers: { 'Content-Disposition': header } }); + expect(response.headers.get('Content-Disposition')).toContain('a\\";x=\\"y\\\\'); + expect(header).toContain('%E4%B8%AD%F0%9F%98%80'); + expect(header).toContain('%27%28%29%2A'); + expect(header).not.toMatch(/[\r\n]/); + }); +}); diff --git a/src/lib/server/storage/content-disposition.ts b/src/lib/server/storage/content-disposition.ts new file mode 100644 index 00000000..e713cd06 --- /dev/null +++ b/src/lib/server/storage/content-disposition.ts @@ -0,0 +1,12 @@ +/** Encode an untrusted object filename for an HTTP attachment response. */ +export function contentDispositionFilename(filename: string): string { + const quoted = filename + .replace(/[\r\n]/g, '') + .replace(/[^\x20-\x7e]/g, '_') + .replace(/(["\\])/g, '\\$1'); + const encoded = encodeURIComponent(filename.toWellFormed()).replace( + /['()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ); + return `attachment; filename="${quoted}"; filename*=UTF-8''${encoded}`; +} diff --git a/src/lib/server/storage/directory-tree.test.ts b/src/lib/server/storage/directory-tree.test.ts new file mode 100644 index 00000000..22736320 --- /dev/null +++ b/src/lib/server/storage/directory-tree.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import type { TreemapNode } from '$lib/storage/details-types.js'; +import { + DEPTH_LIST_MAX_ENTRIES, + DirectorySizeAggregator, + TREEMAP_MAX_LEAVES +} from './directory-tree.js'; + +const PREFIX = 'data/'; + +function leaves(node: TreemapNode): TreemapNode[] { + return node.children?.length ? node.children.flatMap(leaves) : [node]; +} + +describe('DirectorySizeAggregator', () => { + it('sums totals, depth lists and the tree across batches', () => { + const agg = new DirectorySizeAggregator(PREFIX, 100); + const t1 = new Date('2026-01-01T00:00:00Z'); + const t2 = new Date('2026-02-01T00:00:00Z'); + expect( + agg.add([ + { key: 'data/', size: 0 }, + { key: 'data/a.txt', size: 10, lastModified: t1 } + ]) + ).toBe(true); + agg.add([ + { key: 'data/sub/', size: 0 }, + { key: 'data/sub/b.txt', size: 5, lastModified: t2 }, + { key: 'data/sub/deep/c.txt', size: 7, lastModified: t1 } + ]); + + const result = agg.result(42); + expect(result).toMatchObject({ + totalSize: 22, + totalKeys: 5, + totalFiles: 3, + totalDirectories: 2, + maxDepth: 3, + durationMs: 42, + truncated: false + }); + expect(result.childrenByDepth[1].items).toEqual([ + { name: 'sub/', size: 12, lastModified: t2.toISOString(), isDirectory: true }, + { name: 'a.txt', size: 10, lastModified: t1.toISOString(), isDirectory: false } + ]); + expect(result.childrenByDepth[3].items.map((i) => i.name)).toEqual(['sub/deep/c.txt']); + expect(result.tree.name).toBe('data'); + expect(result.tree.size).toBe(22); + expect(leaves(result.tree).map((l) => l.fullKey)).toEqual([ + 'data/sub/deep/c.txt', + 'data/sub/b.txt', + 'data/a.txt' + ]); + }); + + it('keeps a file and a folder of the same name apart', () => { + const agg = new DirectorySizeAggregator(PREFIX, 100); + agg.add([ + { key: 'data/x', size: 1 }, + { key: 'data/x/y', size: 2 } + ]); + const { childrenByDepth, tree } = agg.result(0); + expect(childrenByDepth[1].items.map((i) => [i.name, i.size, i.isDirectory])).toEqual([ + ['x/', 2, true], + ['x', 1, false] + ]); + expect(leaves(tree).map((l) => l.fullKey)).toEqual(['data/x/y', 'data/x']); + }); + + it('gives an empty folder marker its key with the trailing slash', () => { + const agg = new DirectorySizeAggregator(PREFIX, 100); + agg.add([{ key: 'data/empty/', size: 0 }]); + expect(leaves(agg.result(0).tree)).toEqual([ + { name: 'empty', size: 0, path: undefined, fullKey: 'data/empty/' } + ]); + }); + + it('stops at the key limit and marks the result as truncated', () => { + const agg = new DirectorySizeAggregator(PREFIX, 3); + const batch = Array.from({ length: 5 }, (_, i) => ({ key: `data/f${i}`, size: 1 })); + expect(agg.add(batch)).toBe(false); + expect(agg.add([{ key: 'data/later', size: 1 }])).toBe(false); + const result = agg.result(0); + expect(result.totalKeys).toBe(3); + expect(result.totalSize).toBe(3); + expect(result.truncated).toBe(true); + }); + + it('handles very deep and very wide listings without a spread', () => { + const agg = new DirectorySizeAggregator(PREFIX, 300_000); + const keys = Array.from({ length: 200_000 }, (_, i) => ({ key: `data/f${i}`, size: i })); + agg.add(keys); + agg.add([{ key: 'data/' + 'd/'.repeat(300) + 'leaf', size: 1 }]); + const result = agg.result(0); + expect(result.maxDepth).toBe(301); + expect(result.totalKeys).toBe(200_001); + }); + + it('prunes the treemap to the largest files and groups the rest', () => { + const agg = new DirectorySizeAggregator(PREFIX, 10_000); + // Many small files in folders without any large file, plus large files at the top. + for (let d = 0; d < 50; d++) { + agg.add(Array.from({ length: 20 }, (_, i) => ({ key: `data/d${d}/s${i}`, size: 1 }))); + } + agg.add( + Array.from({ length: TREEMAP_MAX_LEAVES }, (_, i) => ({ key: `data/big${i}`, size: 1000 })) + ); + + const { tree } = agg.result(0); + const all = leaves(tree); + const real = all.filter((l) => l.moreCount === undefined); + const more = all.filter((l) => l.moreCount !== undefined); + expect(real).toHaveLength(TREEMAP_MAX_LEAVES); + expect(real.every((l) => l.size === 1000)).toBe(true); + // All 50 small folders collapse into a single node at the top level. + expect(more).toEqual([{ name: '', size: 1000, moreCount: 1000 }]); + expect(tree.children!.reduce((sum, c) => sum + c.size, 0)).toBe(tree.size); + }); + + it('caps each depth list and reports what was left out', () => { + const agg = new DirectorySizeAggregator(PREFIX, 10_000); + const extra = 5; + agg.add( + Array.from({ length: DEPTH_LIST_MAX_ENTRIES + extra }, (_, i) => ({ + key: `data/f${i}`, + size: i + 1 + })) + ); + const list = agg.result(0).childrenByDepth[1]; + expect(list.items).toHaveLength(DEPTH_LIST_MAX_ENTRIES); + expect(list.items[0].size).toBe(DEPTH_LIST_MAX_ENTRIES + extra); + expect(list.omittedCount).toBe(extra); + expect(list.omittedSize).toBe(1 + 2 + 3 + 4 + 5); + }); + + it('returns an empty result for an empty folder', () => { + const result = new DirectorySizeAggregator(PREFIX, 10).result(0); + expect(result).toMatchObject({ totalKeys: 0, maxDepth: 1, truncated: false }); + expect(result.tree).toEqual({ name: 'data', size: 0 }); + expect(result.childrenByDepth[1]).toEqual({ items: [], omittedCount: 0, omittedSize: 0 }); + }); +}); diff --git a/src/lib/server/storage/directory-tree.ts b/src/lib/server/storage/directory-tree.ts new file mode 100644 index 00000000..1e1f7fb2 --- /dev/null +++ b/src/lib/server/storage/directory-tree.ts @@ -0,0 +1,216 @@ +import type { + DirectoryChildItem, + DirectoryChildList, + DirectorySizeResult, + TreemapNode +} from '$lib/storage/details-types.js'; + +/** Objects listed at most per calculation; larger directories get a partial result. */ +export const DIRECTORY_SIZE_MAX_KEYS = 10_000; +/** Files drawn in the treemap; smaller ones are grouped into "N more" nodes. */ +export const TREEMAP_MAX_LEAVES = 30; +/** Entries per depth in the contents list; smaller ones are only counted. */ +export const DEPTH_LIST_MAX_ENTRIES = 1000; + +interface TrieNode { + name: string; + size: number; + lastModified: Date | undefined; + isDirectory: boolean; + /** Keyed by name, with a trailing `/` for folders, so a file and a folder of + * the same name stay apart. Created only for folders. */ + children?: Map; +} + +type ListedKey = { key: string; size: number; lastModified?: Date }; + +/** + * Aggregates a recursive listing batch by batch, without keeping the keys. + * Stops accepting keys once `maxKeys` have been added. + */ +export class DirectorySizeAggregator { + readonly #prefix: string; + readonly #maxKeys: number; + readonly #root: TrieNode; + #totalKeys = 0; + #totalSize = 0; + #totalFiles = 0; + #totalDirectories = 0; + #maxDepth = 1; + #truncated = false; + + constructor(prefix: string, maxKeys: number) { + this.#prefix = prefix; + this.#maxKeys = maxKeys; + const rootName = prefix.split('/').filter(Boolean).pop() || '(root)'; + this.#root = { + name: rootName, + size: 0, + lastModified: undefined, + isDirectory: true, + children: new Map() + }; + } + + get totalKeys(): number { + return this.#totalKeys; + } + + get totalSize(): number { + return this.#totalSize; + } + + get truncated(): boolean { + return this.#truncated; + } + + /** Adds a batch. Returns `false` once the key limit is reached. */ + add(batch: ListedKey[]): boolean { + for (const item of batch) { + if (this.#totalKeys >= this.#maxKeys) { + this.#truncated = true; + return false; + } + this.#addKey(item); + } + return true; + } + + #addKey({ key, size, lastModified }: ListedKey) { + this.#totalKeys++; + this.#totalSize += size; + if (key.endsWith('/')) this.#totalDirectories++; + else this.#totalFiles++; + + const relative = key.slice(this.#prefix.length); + const parts = relative.split('/').filter(Boolean); + if (parts.length === 0) return; + if (parts.length > this.#maxDepth) this.#maxDepth = parts.length; + + this.#root.size += size; + let current = this.#root; + for (let i = 0; i < parts.length; i++) { + const isDirectory = i < parts.length - 1 || relative.endsWith('/'); + const childKey = isDirectory ? parts[i] + '/' : parts[i]; + current.children ??= new Map(); + let child = current.children.get(childKey); + if (!child) { + child = { name: parts[i], size: 0, lastModified: undefined, isDirectory }; + current.children.set(childKey, child); + } + child.size += size; + if (lastModified && (!child.lastModified || lastModified > child.lastModified)) { + child.lastModified = lastModified; + } + current = child; + } + } + + result(durationMs: number): DirectorySizeResult { + return { + type: 'complete', + totalSize: this.#totalSize, + totalKeys: this.#totalKeys, + totalFiles: this.#totalFiles, + totalDirectories: this.#totalDirectories, + tree: this.#treemap(), + childrenByDepth: this.#childrenByDepth(), + maxDepth: this.#maxDepth, + durationMs, + truncated: this.#truncated + }; + } + + /** + * The treemap keeps the TREEMAP_MAX_LEAVES largest leaves. Everything else + * in a folder, including subfolders without a kept leaf, becomes one + * `moreCount` node, so the output stays small however large the folder is. + */ + #treemap(): TreemapNode { + const leaves: TrieNode[] = []; + const collect = (node: TrieNode) => { + if (!node.children?.size) leaves.push(node); + else for (const child of node.children.values()) collect(child); + }; + for (const child of this.#root.children!.values()) collect(child); + + if (leaves.length === 0) return { name: this.#root.name, size: this.#root.size }; + + leaves.sort((a, b) => b.size - a.size); + const kept = new Set(leaves.slice(0, TREEMAP_MAX_LEAVES)); + + // Leaf count and whether a kept leaf is inside, per folder. + const stats = new Map(); + const measure = (node: TrieNode): { leaves: number; hasKept: boolean } => { + if (!node.children?.size) return { leaves: 1, hasKept: kept.has(node) }; + let count = 0; + let hasKept = false; + for (const child of node.children.values()) { + const s = measure(child); + count += s.leaves; + hasKept ||= s.hasKept; + } + const s = { leaves: count, hasKept }; + stats.set(node, s); + return s; + }; + measure(this.#root); + + const convert = (node: TrieNode, parentPath: string, isRoot: boolean): TreemapNode => { + if (!node.children?.size) { + return { + name: node.name, + size: node.size, + path: parentPath || undefined, + fullKey: this.#prefix + parentPath + node.name + (node.isDirectory ? '/' : '') + }; + } + const childPath = isRoot ? '' : parentPath + node.name + '/'; + const children: TreemapNode[] = []; + let moreSize = 0; + let moreCount = 0; + for (const child of node.children.values()) { + const isLeaf = !child.children?.size; + if (isLeaf ? kept.has(child) : stats.get(child)!.hasKept) { + children.push(convert(child, childPath, false)); + } else { + moreSize += child.size; + moreCount += isLeaf ? 1 : stats.get(child)!.leaves; + } + } + if (moreCount > 0) children.push({ name: '', size: moreSize, moreCount }); + children.sort((a, b) => b.size - a.size); + return { name: node.name, size: node.size, children }; + }; + return convert(this.#root, '', true); + } + + #childrenByDepth(): Record { + const byDepth: DirectoryChildItem[][] = Array.from({ length: this.#maxDepth }, () => []); + const walk = (node: TrieNode, path: string, depth: number) => { + for (const child of node.children?.values() ?? []) { + const name = path + child.name + (child.isDirectory ? '/' : ''); + byDepth[depth - 1].push({ + name, + size: child.size, + lastModified: child.lastModified?.toISOString(), + isDirectory: child.isDirectory + }); + if (child.children) walk(child, path + child.name + '/', depth + 1); + } + }; + walk(this.#root, '', 1); + + const result: Record = {}; + byDepth.forEach((entries, index) => { + entries.sort((a, b) => b.size - a.size); + const omitted = entries.slice(DEPTH_LIST_MAX_ENTRIES); + result[index + 1] = { + items: entries.slice(0, DEPTH_LIST_MAX_ENTRIES), + omittedCount: omitted.length, + omittedSize: omitted.reduce((sum, entry) => sum + entry.size, 0) + }; + }); + return result; + } +} diff --git a/src/lib/server/storage/download-manifests.test.ts b/src/lib/server/storage/download-manifests.test.ts new file mode 100644 index 00000000..61505cde --- /dev/null +++ b/src/lib/server/storage/download-manifests.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + insertValues: vi.fn(), + deleteWhere: vi.fn(), + selectWhere: vi.fn(), + getProvider: vi.fn(), + getConnectionForUser: vi.fn(), + listKeys: vi.fn(), + getMetadata: vi.fn(), + getObject: vi.fn(), + orderBy: vi.fn() +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + insert: () => ({ values: mocks.insertValues }), + delete: () => ({ where: mocks.deleteWhere }), + select: () => ({ from: () => ({ where: mocks.selectWhere }) }) + } +})); +vi.mock('./utils.js', () => ({ getProvider: mocks.getProvider })); +vi.mock('./connections-db.js', () => ({ getConnectionForUser: mocks.getConnectionForUser })); + +import { PgDialect } from 'drizzle-orm/pg-core'; +import type { SQL } from 'drizzle-orm'; +import { + clearDownloadHistory, + createDownloadManifest, + openDownloadManifestPart, + recreateDownloadManifest +} from './download-manifests.js'; + +const config = { + type: 's3' as const, + host: 's3.example.test', + accessStyle: 'Path' as const, + region: { name: 'eu-central-1' } +}; + +function queryResult(rows: unknown[]) { + return { limit: async () => rows }; +} + +const MANIFEST_ID = '00000000-0000-4000-8000-0000000000aa'; + +describe('download manifests', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listKeys.mockResolvedValue(['folder/a.txt', 'folder/b.txt']); + mocks.selectWhere.mockReturnValue({ limit: () => ({ getSQL: () => ({}) }) }); + mocks.getMetadata.mockResolvedValue({ size: 5 }); + mocks.getObject.mockResolvedValue({ + stream: new ReadableStream({ start: (controller) => controller.close() }) + }); + mocks.getProvider.mockReturnValue({ + listAllKeysProgressively: async ( + _prefix: string, + onBatch: (batch: Array<{ key: string }>) => void + ) => onBatch((await mocks.listKeys()).map((key: string) => ({ key }))), + getMetadata: mocks.getMetadata, + getObject: mocks.getObject + }); + mocks.insertValues.mockReturnValue({ returning: async () => [{ id: MANIFEST_ID }] }); + mocks.deleteWhere.mockResolvedValue(undefined); + }); + + it('expands folders and persists only resolved metadata, not credentials', async () => { + const manifest = await createDownloadManifest({ + userId: 'user-1', + connectionId: '00000000-0000-4000-8000-000000000001', + bucket: 'bucket', + prefix: 'folder/', + keys: ['folder/'], + config: { ...config, credentials: { accessKey: 'secret-access', secretKey: 'secret-key' } } + }); + + expect(manifest.files).toHaveLength(1); + expect(manifest.files[0]?.filename).toBe('folder.zip'); + // The reported size includes all ZIP headers and records, so browsers can + // report progress against the actual final download size. + expect(manifest.files[0]?.size).toBeGreaterThan(10); + const persisted = mocks.insertValues.mock.calls[0]?.[0] as Record; + expect(persisted).toMatchObject({ + userId: 'user-1', + bucket: 'bucket', + prefix: 'folder/', + connectionId: '00000000-0000-4000-8000-000000000001', + format: 'zip' + }); + expect(persisted).not.toHaveProperty('archiveSize'); + expect(JSON.stringify(persisted)).not.toContain('archive_size'); + expect(JSON.stringify(persisted)).not.toContain('secret-access'); + expect(JSON.stringify(persisted)).not.toContain('secret-key'); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it('uses individual files for up to three file keys', async () => { + const manifest = await createDownloadManifest({ + userId: 'user-1', + connectionId: '00000000-0000-4000-8000-000000000001', + bucket: 'bucket', + prefix: '', + keys: ['a.txt', 'b.txt', 'c.txt'], + config + }); + expect(manifest.files.map((file) => file.filename)).toEqual(['a.txt', 'b.txt', 'c.txt']); + }); + + it('clears only the history of the given connection', async () => { + await clearDownloadHistory('user-1', '00000000-0000-4000-8000-000000000001'); + const { sql, params } = new PgDialect().sqlToQuery(mocks.deleteWhere.mock.calls[0][0] as SQL); + expect(sql).toContain('"user_id" = $1'); + expect(sql).toContain('"connection_id" = $2'); + expect(params).toEqual(['user-1', '00000000-0000-4000-8000-000000000001']); + }); + + it('treats a manifest id that is not a UUID as not found without querying', async () => { + await expect(openDownloadManifestPart('user-1', 'foo', 1)).resolves.toBeNull(); + await expect(recreateDownloadManifest('user-1', 'foo', ['a.txt'])).resolves.toBeNull(); + expect(mocks.selectWhere).not.toHaveBeenCalled(); + }); + + it('does not open an expired, foreign, or deleted-connection manifest', async () => { + mocks.selectWhere.mockReturnValue(queryResult([])); + await expect(openDownloadManifestPart('user-1', MANIFEST_ID, 1)).resolves.toBeNull(); + + mocks.selectWhere.mockReturnValue( + queryResult([ + { + id: MANIFEST_ID, + bucket: 'bucket', + connectionId: '00000000-0000-4000-8000-000000000001', + entries: [{ key: 'a.txt', size: 5, isDirectory: false }], + archive: null, + expiresAt: new Date(Date.now() + 60_000) + } + ]) + ); + mocks.getConnectionForUser.mockResolvedValue(null); + await expect(openDownloadManifestPart('user-1', MANIFEST_ID, 1)).resolves.toBeNull(); + expect(mocks.deleteWhere).toHaveBeenCalledWith(expect.anything()); + }); + + it('validates and recreates selections with refreshed object sizes', async () => { + mocks.selectWhere.mockReturnValue( + queryResult([ + { + id: MANIFEST_ID, + bucket: 'bucket', + connectionId: '00000000-0000-4000-8000-000000000001', + entries: [ + { key: 'a.txt', size: 5, isDirectory: false }, + { key: 'folder/', size: 0, isDirectory: true }, + { key: 'folder/b.txt', size: 7, isDirectory: false }, + { key: 'folder/c.txt', size: 8, isDirectory: false } + ], + archive: 'original.zip', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date() + } + ]) + ); + mocks.getConnectionForUser.mockResolvedValue(config); + mocks.getMetadata.mockImplementation(async (key: string) => ({ + size: key === 'a.txt' ? 40 : 9 + })); + await expect(recreateDownloadManifest('user-1', MANIFEST_ID, ['a.txt'])).resolves.toMatchObject( + { files: [{ filename: 'a.txt', size: 40 }] } + ); + const persisted = mocks.insertValues.mock.calls[0]?.[0] as { entries: Array<{ size: number }> }; + expect(persisted.entries).toEqual([{ key: 'a.txt', size: 40, isDirectory: false }]); + await expect( + recreateDownloadManifest('user-1', MANIFEST_ID, ['other.txt']) + ).resolves.toBeNull(); + await expect( + recreateDownloadManifest('user-1', MANIFEST_ID, ['a.txt', 'a.txt']) + ).resolves.toBeNull(); + await expect(recreateDownloadManifest('user-1', MANIFEST_ID, [])).resolves.toBeNull(); + }); +}); diff --git a/src/lib/server/storage/download-manifests.ts b/src/lib/server/storage/download-manifests.ts new file mode 100644 index 00000000..74937ecb --- /dev/null +++ b/src/lib/server/storage/download-manifests.ts @@ -0,0 +1,362 @@ +import { and, desc, eq, gt, lte, inArray } from 'drizzle-orm'; +import { error, isHttpError } from '@sveltejs/kit'; +import { z } from 'zod'; +import { db } from '$lib/server/db.js'; +import { downloadHistoryRetentionMs } from '$lib/server/feature-flags.js'; +import { logger } from '$lib/server/logging'; +import { storageDownloadManifests } from '$lib/server/schema.js'; +import { getConnectionForUser } from './connections-db.js'; +import type { StorageProvider } from './provider.js'; +import type { S3ConnectionConfig } from './types.js'; +import { getProvider } from './utils.js'; +import { withStorageHttpErrors } from './wrap-provider.js'; +import { createZipStream, type ZipEntry, zipStreamSize } from './zip-stream.js'; + +const log = logger.child({ module: 'storage-download-manifests' }); + +export type DownloadEntry = ZipEntry; + +export interface DownloadFile { + filename: string; + size: number; + part: number; +} + +export interface DownloadManifest { + id: string; + files: DownloadFile[]; + expiresAt: Date; +} + +export interface DownloadHistoryEntry { + id: string; + bucket: string; + connectionId: string; + prefix: string; + entries: DownloadEntry[]; + archive: boolean; + archiveFilename: string | null; + expiresAt: Date; + createdAt: Date; +} + +function fileName(key: string): string { + return key.split('/').filter(Boolean).pop() ?? key; +} + +function archiveBaseName(bucket: string, prefix: string | undefined, keys: string[]): string { + if (keys.length === 1 && keys[0].endsWith('/')) { + return fileName(keys[0]).replace(/\/$/, '') || bucket; + } + return fileName(prefix ?? '').replace(/\/$/, '') || bucket; +} + +export function archiveFileName(base: string): string { + return `${base}.zip`; +} + +async function removeExpiredManifests(): Promise { + const expired = db + .select({ id: storageDownloadManifests.id }) + .from(storageDownloadManifests) + .where(lte(storageDownloadManifests.expiresAt, new Date())) + .limit(100); + await db.delete(storageDownloadManifests).where(inArray(storageDownloadManifests.id, expired)); +} + +async function mapMetadata(items: T[], resolve: (item: T) => Promise): Promise { + const results: R[] = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(8, items.length) }, async () => { + while (next < items.length) { + const index = next++; + results[index] = await resolve(items[index]); + } + }) + ); + return results; +} + +function shouldArchive(keys: string[]): boolean { + return keys.length > 3 || keys.some((key) => key.endsWith('/')); +} + +async function expandKeys(provider: StorageProvider, keys: string[]): Promise { + const expanded = new Set(); + for (const key of keys) { + expanded.add(key); + if (key.endsWith('/')) + await provider.listAllKeysProgressively(key, (batch) => { + for (const item of batch) expanded.add(item.key); + if (expanded.size > 10_000) throw error(400, 'Download selection exceeds 10000 entries'); + }); + } + return mapMetadata([...expanded], async (key) => { + if (key.endsWith('/')) return { key, size: 0, isDirectory: true }; + const metadata = await provider.getMetadata(key); + return { key, size: metadata.size, isDirectory: false }; + }); +} + +/** + * Build the manifest file list. For archives the reported size is the + * exact output size is derived from the immutable manifest metadata. + */ +function filesFor( + entries: DownloadEntry[], + prefix: string, + archive: boolean, + archiveName: string +): DownloadFile[] { + return archive + ? [ + { + filename: archiveName, + size: zipStreamSize(entries, prefix), + part: 1 + } + ] + : entries.map((entry, index) => ({ + filename: fileName(entry.key), + size: entry.size, + part: index + 1 + })); +} + +async function persistManifest(input: { + userId: string; + connectionId: string; + bucket: string; + prefix: string; + entries: DownloadEntry[]; + archive: boolean; + archiveName: string; +}): Promise { + const expiresAt = new Date(Date.now() + downloadHistoryRetentionMs); + const [manifest] = await db + .insert(storageDownloadManifests) + .values({ + userId: input.userId, + connectionId: input.connectionId, + bucket: input.bucket, + prefix: input.prefix, + entries: input.entries, + format: input.archive ? 'zip' : 'direct', + archive: input.archive ? input.archiveName : null, + expiresAt + }) + .returning({ id: storageDownloadManifests.id }); + return { + id: manifest.id, + files: filesFor(input.entries, input.prefix, input.archive, input.archiveName), + expiresAt + }; +} + +/** Resolve all metadata before inserting one immutable manifest row. */ +export async function createDownloadManifest(input: { + userId: string; + connectionId: string; + bucket: string; + prefix: string; + keys: string[]; + config: S3ConnectionConfig; +}): Promise { + await removeExpiredManifests(); + const archive = shouldArchive(input.keys); + const provider = withStorageHttpErrors(getProvider(input.config, input.bucket)); + const entries = archive + ? await expandKeys(provider, input.keys) + : await mapMetadata(input.keys, async (key) => { + const metadata = await provider.getMetadata(key); + return { key, size: metadata.size, isDirectory: false }; + }); + const archiveName = archiveFileName(archiveBaseName(input.bucket, input.prefix, input.keys)); + const manifest = await persistManifest({ ...input, entries, archive, archiveName }); + log.info( + { manifest_id: manifest.id, bucket: input.bucket, file_count: manifest.files.length }, + 'download manifest created' + ); + return manifest; +} + +async function loadOwnedManifest(userId: string, id: string): Promise { + // The id column is a UUID; Postgres rejects other input with an error. + if (!z.uuid().safeParse(id).success) return null; + const [row] = await db + .select() + .from(storageDownloadManifests) + .where( + and( + eq(storageDownloadManifests.id, id), + eq(storageDownloadManifests.userId, userId), + gt(storageDownloadManifests.expiresAt, new Date()) + ) + ) + .limit(1); + if (!row) return null; + const entries = row.entries as DownloadEntry[]; + return { + id: row.id, + bucket: row.bucket, + connectionId: row.connectionId, + prefix: row.prefix, + entries, + archive: (row.format ?? 'zip') === 'zip' && row.archive !== null, + archiveFilename: row.archive, + expiresAt: row.expiresAt, + createdAt: row.createdAt + }; +} + +/** Create a fresh history entry from a checked subset of an existing entry. */ +export async function recreateDownloadManifest( + userId: string, + id: string, + selectedKeys: string[], + signal?: AbortSignal +): Promise { + if (selectedKeys.length === 0 || new Set(selectedKeys).size !== selectedKeys.length) return null; + const original = await loadOwnedManifest(userId, id); + if (!original) return null; + const config = await getConnectionForUser(userId, original.connectionId); + if (!config) { + await db.delete(storageDownloadManifests).where(eq(storageDownloadManifests.id, id)); + return null; + } + + const byKey = new Map(original.entries.map((entry) => [entry.key, entry])); + if (selectedKeys.some((key) => !byKey.has(key))) return null; + const selected = selectedKeys.map((key) => byKey.get(key)!); + const expanded = [ + ...new Map( + selected + .flatMap((entry) => + entry.isDirectory + ? original.entries.filter( + (candidate) => candidate.key === entry.key || candidate.key.startsWith(entry.key) + ) + : [entry] + ) + .map((entry) => [entry.key, entry]) + ).values() + ]; + const provider = withStorageHttpErrors(getProvider(config, original.bucket)); + const entries = await mapMetadata(expanded, async (entry) => { + signal?.throwIfAborted(); + if (entry.isDirectory) return { ...entry, size: 0 }; + try { + const metadata = await provider.getMetadata(entry.key); + return { ...entry, size: metadata.size }; + } catch (err) { + if (isHttpError(err) && err.status === 404) throw error(404, entry.key); + throw err; + } + }); + const nonDirectoryEntries = entries.filter((entry) => !entry.isDirectory); + signal?.throwIfAborted(); + const archive = nonDirectoryEntries.length !== 1; + return persistManifest({ + userId, + connectionId: original.connectionId, + bucket: original.bucket, + prefix: original.prefix, + entries: archive ? entries : nonDirectoryEntries, + archive, + archiveName: archiveFileName(archiveBaseName(original.bucket, original.prefix, selectedKeys)) + }); +} + +/** List current connection-scoped history and remove records whose connection was deleted. */ +export async function listDownloadHistory( + userId: string, + connectionId?: string, + offset = 0 +): Promise { + await removeExpiredManifests(); + const conditions = [ + eq(storageDownloadManifests.userId, userId), + gt(storageDownloadManifests.expiresAt, new Date()) + ]; + if (connectionId) conditions.push(eq(storageDownloadManifests.connectionId, connectionId)); + const rows = await db + .select() + .from(storageDownloadManifests) + .where(and(...conditions)) + .orderBy(desc(storageDownloadManifests.createdAt), desc(storageDownloadManifests.id)) + .limit(50) + .offset(offset); + const history: DownloadHistoryEntry[] = []; + const available = new Map(); + for (const row of rows) { + if (!available.has(row.connectionId)) + available.set(row.connectionId, !!(await getConnectionForUser(userId, row.connectionId))); + if (!available.get(row.connectionId)) { + await db.delete(storageDownloadManifests).where(eq(storageDownloadManifests.id, row.id)); + continue; + } + history.push({ + id: row.id, + bucket: row.bucket, + connectionId: row.connectionId, + prefix: row.prefix, + entries: row.entries as DownloadEntry[], + archive: (row.format ?? 'zip') === 'zip' && row.archive !== null, + archiveFilename: row.archive, + expiresAt: row.expiresAt, + createdAt: row.createdAt + }); + } + return history; +} + +/** Remove the retained download manifests of one user's storage connection. */ +export async function clearDownloadHistory(userId: string, connectionId: string): Promise { + await db + .delete(storageDownloadManifests) + .where( + and( + eq(storageDownloadManifests.userId, userId), + eq(storageDownloadManifests.connectionId, connectionId) + ) + ); + log.info({ user_id: userId, connection_id: connectionId }, 'download history cleared'); +} + +/** Load an owned, unexpired manifest and resolve its current encrypted connection. */ +export async function openDownloadManifestPart( + userId: string, + id: string, + part: number +): Promise<{ stream: ReadableStream; file: DownloadFile } | null> { + const manifest = await loadOwnedManifest(userId, id); + if (!manifest) return null; + const config = await getConnectionForUser(userId, manifest.connectionId); + if (!config) { + log.warn( + { manifest_id: id, connection_id: manifest.connectionId }, + 'download connection unavailable' + ); + await db.delete(storageDownloadManifests).where(eq(storageDownloadManifests.id, id)); + return null; + } + const provider = withStorageHttpErrors(getProvider(config, manifest.bucket)); + if (manifest.archive) { + if (part !== 1 || !manifest.archiveFilename) return null; + return { + stream: createZipStream(provider, manifest.entries, manifest.prefix), + file: { + filename: manifest.archiveFilename, + size: zipStreamSize(manifest.entries, manifest.prefix), + part: 1 + } + }; + } + const entry = manifest.entries[part - 1]; + if (!entry || entry.isDirectory) return null; + return { + stream: (await provider.getObject(entry.key)).stream, + file: { filename: fileName(entry.key), size: entry.size, part } + }; +} diff --git a/src/lib/server/storage/encryption-key.ts b/src/lib/server/storage/encryption-key.ts new file mode 100644 index 00000000..480a46b7 --- /dev/null +++ b/src/lib/server/storage/encryption-key.ts @@ -0,0 +1,37 @@ +import { env } from '$env/dynamic/private'; +import { logger } from '$lib/server/logging'; + +const log = logger.child({ module: 'storage-encryption-key' }); + +let _key: Buffer | undefined; + +/** + * Returns the AES-256-GCM encryption key for storage connection credentials. + * Validated and cached on first call; throws a clear error if the env var is + * missing or invalid. Exported as a function (not a module-level constant) so + * that the Vite SSR module runner can import this file without throwing during + * test runs where the env var is not set. + */ +export function storageEncryptionKey(): Buffer { + if (_key) return _key; + + const raw = env.STORAGE_ENCRYPTION_KEY; + + if (!raw) { + log.error('STORAGE_ENCRYPTION_KEY environment variable is not set'); + throw new Error( + 'STORAGE_ENCRYPTION_KEY is required. Set it to a 64-character hex string (32 bytes).' + ); + } + + if (raw.length !== 64 || !/^[0-9a-fA-F]+$/.test(raw)) { + log.error('STORAGE_ENCRYPTION_KEY is not a valid 64-character hex string'); + throw new Error( + 'STORAGE_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' + + "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"" + ); + } + + _key = Buffer.from(raw, 'hex'); + return _key; +} diff --git a/src/lib/server/storage/encryption.test.ts b/src/lib/server/storage/encryption.test.ts new file mode 100644 index 00000000..c64bb797 --- /dev/null +++ b/src/lib/server/storage/encryption.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { encrypt, decrypt, fingerprint } from './encryption.js'; + +const TEST_KEY = Buffer.from('a'.repeat(64), 'hex'); // 32 bytes of 0xaa + +describe('encrypt / decrypt', () => { + it('round-trips a plaintext string', () => { + const plaintext = 'hello, world!'; + const ciphertext = encrypt(plaintext, TEST_KEY); + expect(decrypt(ciphertext, TEST_KEY)).toBe(plaintext); + }); + + it('round-trips an empty string', () => { + const ciphertext = encrypt('', TEST_KEY); + expect(decrypt(ciphertext, TEST_KEY)).toBe(''); + }); + + it('round-trips a JSON payload', () => { + const payload = JSON.stringify({ + endpoint: 'https://minio.example.com:9000', + region: 'eu-central-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + }); + const ciphertext = encrypt(payload, TEST_KEY); + expect(decrypt(ciphertext, TEST_KEY)).toBe(payload); + }); + + it('produces different ciphertexts for the same plaintext (random IV)', () => { + const plaintext = 'same input'; + const c1 = encrypt(plaintext, TEST_KEY); + const c2 = encrypt(plaintext, TEST_KEY); + expect(c1).not.toBe(c2); + // Both must decrypt to the same plaintext. + expect(decrypt(c1, TEST_KEY)).toBe(plaintext); + expect(decrypt(c2, TEST_KEY)).toBe(plaintext); + }); + + it('throws when the ciphertext is tampered with', () => { + const ciphertext = encrypt('secret', TEST_KEY); + // Flip a byte in the middle of the ciphertext. + const buf = Buffer.from(ciphertext, 'hex'); + buf[16] ^= 0xff; + const tampered = buf.toString('hex'); + expect(() => decrypt(tampered, TEST_KEY)).toThrow(); + }); + + it('throws when the wrong key is used', () => { + const wrongKey = Buffer.alloc(32, 0x00); + const ciphertext = encrypt('secret', TEST_KEY); + expect(() => decrypt(ciphertext, wrongKey)).toThrow(); + }); +}); + +describe('fingerprint', () => { + it('returns a 64-char hex string', () => { + const fp = fingerprint( + { + endpoint: 'https://s3.example.com', + region: 'us-east-1', + accessKeyId: 'key', + secretAccessKey: 'secret' + }, + TEST_KEY + ); + expect(fp).toMatch(/^[0-9a-f]{64}$/); + }); + + it('is deterministic for the same input', () => { + const creds = { + endpoint: 'https://s3.example.com', + region: 'us-east-1', + accessKeyId: 'key', + secretAccessKey: 'secret' + }; + expect(fingerprint(creds, TEST_KEY)).toBe(fingerprint(creds, TEST_KEY)); + }); + + it('differs when endpoint changes', () => { + const base = { region: 'us-east-1', accessKeyId: 'key', secretAccessKey: 'secret' }; + const fp1 = fingerprint({ ...base, endpoint: 'https://s3.example.com' }, TEST_KEY); + const fp2 = fingerprint({ ...base, endpoint: 'https://other.example.com' }, TEST_KEY); + expect(fp1).not.toBe(fp2); + }); + + it('differs when access key changes', () => { + const base = { + endpoint: 'https://s3.example.com', + region: 'us-east-1', + secretAccessKey: 'secret' + }; + const fp1 = fingerprint({ ...base, accessKeyId: 'key1' }, TEST_KEY); + const fp2 = fingerprint({ ...base, accessKeyId: 'key2' }, TEST_KEY); + expect(fp1).not.toBe(fp2); + }); + + it('distinguishes credentials whose fields contain the former delimiter', () => { + const first = { + endpoint: 'endpoint|region', + region: 'access', + accessKeyId: 'key', + secretAccessKey: 'secret' + }; + const second = { + endpoint: 'endpoint', + region: 'region|access', + accessKeyId: 'key', + secretAccessKey: 'secret' + }; + expect(fingerprint(first, TEST_KEY)).not.toBe(fingerprint(second, TEST_KEY)); + }); + + it('differs when the HMAC key changes', () => { + const creds = { + endpoint: '', + region: 'us-east-1', + accessKeyId: 'key', + secretAccessKey: 'secret' + }; + const otherKey = Buffer.alloc(32, 0x55); + expect(fingerprint(creds, TEST_KEY)).not.toBe(fingerprint(creds, otherKey)); + }); +}); diff --git a/src/lib/server/storage/encryption.ts b/src/lib/server/storage/encryption.ts new file mode 100644 index 00000000..ca0285f8 --- /dev/null +++ b/src/lib/server/storage/encryption.ts @@ -0,0 +1,92 @@ +import { createCipheriv, createDecipheriv, createHmac, randomBytes, hkdfSync } from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96-bit IV recommended for GCM +const AUTH_TAG_LENGTH = 16; // 128-bit authentication tag + +// HKDF parameters for deriving a dedicated HMAC subkey. +// A fixed zero salt is acceptable here because the master key already has +// sufficient entropy. The info label binds the subkey to its specific purpose, +// ensuring AES and HMAC never share key material. +const HMAC_SUBKEY_SALT = Buffer.alloc(32); +const HMAC_SUBKEY_INFO = Buffer.from('storage-connection-fingerprint-v1'); + +/** Derive a dedicated 32-byte HMAC subkey from the master key using HKDF-SHA256. */ +function deriveHmacSubkey(masterKey: Buffer): Buffer { + return Buffer.from(hkdfSync('sha256', masterKey, HMAC_SUBKEY_SALT, HMAC_SUBKEY_INFO, 32)); +} + +/** + * Encrypt a plaintext string using AES-256-GCM. + * + * A random 12-byte IV is generated for each call. The output is the + * hex-encoded concatenation of: IV (12 bytes) | ciphertext | auth tag (16 bytes). + * + * @param plaintext - The string to encrypt. + * @param key - A 32-byte Buffer (256-bit key). + * @returns Hex-encoded string: iv + ciphertext + authTag. + */ +export function encrypt(plaintext: string, key: Buffer): string { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, encrypted, authTag]).toString('hex'); +} + +/** + * Decrypt a hex-encoded AES-256-GCM ciphertext produced by {@link encrypt}. + * + * @param ciphertext - Hex-encoded string: iv + ciphertext + authTag. + * @param key - The same 32-byte Buffer used during encryption. + * @returns The original plaintext string. + * @throws Error if the authentication tag verification fails (tampered data). + */ +export function decrypt(ciphertext: string, key: Buffer): string { + const buf = Buffer.from(ciphertext, 'hex'); + const iv = buf.subarray(0, IV_LENGTH); + const authTag = buf.subarray(buf.length - AUTH_TAG_LENGTH); + const encrypted = buf.subarray(IV_LENGTH, buf.length - AUTH_TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); +} + +/** + * Compute an HMAC-SHA256 fingerprint of the connection credentials. + * Used to detect duplicate connections before inserting a new row. + * + * A dedicated HMAC subkey is derived from the master key via HKDF so that + * the same key material is never used for both AES-256-GCM and HMAC-SHA256. + * + * @param credentials - The connection fields to fingerprint. + * @param key - The 32-byte master key (same key passed to encrypt/decrypt). + * @returns Hex-encoded HMAC-SHA256 digest. + */ +export function fingerprint( + credentials: { + endpoint: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + port?: number; + tls?: { verification: 'Full' | 'None' }; + accessStyle?: string; + }, + key: Buffer +): string { + const hmacKey = deriveHmacSubkey(key); + const material = [ + credentials.endpoint, + credentials.region, + credentials.accessKeyId, + credentials.secretAccessKey, + String(credentials.port ?? ''), + JSON.stringify(credentials.tls ?? null), + credentials.accessStyle ?? '' + ] + .map((field) => `${Buffer.byteLength(field, 'utf8')}:${field}`) + .join(''); + return createHmac('sha256', hmacKey).update(material, 'utf8').digest('hex'); +} diff --git a/src/lib/server/storage/hdfs-provider.test.ts b/src/lib/server/storage/hdfs-provider.test.ts index 09682798..d34db5f4 100644 --- a/src/lib/server/storage/hdfs-provider.test.ts +++ b/src/lib/server/storage/hdfs-provider.test.ts @@ -81,19 +81,12 @@ describe('HDFSStorageProvider', () => { describe('deleteObjects', () => { it('throws "HDFS not implemented"', () => { const provider = new HDFSStorageProvider(config); - expectThrows(() => provider.deleteObjects(['key1', 'key2'])); + expectThrows(() => provider.deleteObjects(['key1', 'key2'], true)); }); it('throws with empty array', () => { const provider = new HDFSStorageProvider(config); - expectThrows(() => provider.deleteObjects([])); - }); - }); - - describe('listAllKeys', () => { - it('throws "HDFS not implemented"', () => { - const provider = new HDFSStorageProvider(config); - expectThrows(() => provider.listAllKeys('prefix/')); + expectThrows(() => provider.deleteObjects([], true)); }); }); }); diff --git a/src/lib/server/storage/hdfs-provider.ts b/src/lib/server/storage/hdfs-provider.ts index 5861498a..6a2bde1f 100644 --- a/src/lib/server/storage/hdfs-provider.ts +++ b/src/lib/server/storage/hdfs-provider.ts @@ -1,6 +1,13 @@ -import type { StorageProvider, ObjectDownload, DeleteObjectsResult } from './provider.js'; +import type { + StorageProvider, + ObjectDownload, + DeleteObjectsResult, + SearchOptions, + SearchResult +} from './provider.js'; import type { HDFSConfig } from './types.js'; import type { StoragePage, StorageMetadata } from '$lib/storage/types.js'; +import type { LifecycleRule, BucketAcl, BucketVersioning } from '$lib/storage/details-types.js'; /* TODO: Remove this file and related HDFS provider code until we have a concrete plan for HDFS support. For now, this serves as a placeholder to prevent compile errors. */ @@ -10,6 +17,10 @@ import type { StoragePage, StorageMetadata } from '$lib/storage/types.js'; export class HDFSStorageProvider implements StorageProvider { constructor(_config: HDFSConfig) {} + listContainers(): Promise { + throw new Error('HDFS not implemented'); + } + listObjects( _prefix: string, _pageSize: number, @@ -43,11 +54,52 @@ export class HDFSStorageProvider implements StorageProvider { throw new Error('HDFS not implemented'); } - deleteObjects(_keys: string[]): Promise { + putObjectIfMatch( + _key: string, + _body: Buffer, + _contentType: string, + _etag: string + ): Promise { + throw new Error('HDFS not implemented'); + } + + deleteObjects(_keys: string[], _expandDirectories: boolean): Promise { + throw new Error('HDFS not implemented'); + } + + prefixExists(_prefix: string, _signal?: AbortSignal): Promise { + throw new Error('HDFS not implemented'); + } + + listAllKeysProgressively( + _prefix: string, + _onBatch: (keys: Array<{ key: string; size: number }>) => void, + _options?: import('./provider.js').ProgressiveListOptions + ): Promise { + throw new Error('HDFS not implemented'); + } + + search(_query: string, _options?: SearchOptions): Promise { + throw new Error('HDFS not implemented'); + } + + getBucketVersioning(): Promise { + throw new Error('HDFS not implemented'); + } + + getBucketLifecycleRules(): Promise { + throw new Error('HDFS not implemented'); + } + + getBucketTags(): Promise | null> { + throw new Error('HDFS not implemented'); + } + + getBucketAcl(): Promise { throw new Error('HDFS not implemented'); } - listAllKeys(_prefix: string): Promise { + copyObject(_sourceKey: string, _destKey: string): Promise { throw new Error('HDFS not implemented'); } } diff --git a/src/lib/server/storage/job-store.test.ts b/src/lib/server/storage/job-store.test.ts new file mode 100644 index 00000000..d11d95a9 --- /dev/null +++ b/src/lib/server/storage/job-store.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { + createJob, + getJob, + cancelJob, + completeJob, + failJob, + setJobCancellation, + recordJobItem, + finishJob +} from './job-store.js'; + +describe('job ownership and lifecycle', () => { + it('rejects invalid identifiers and prevents replacement', () => { + expect(() => createJob('invalid', 'owner')).toThrow(); + const id = randomUUID(); + createJob(id, 'owner'); + expect(() => createJob(id, 'another')).toThrow(); + expect(getJob(id, 'owner')?.status).toBe('running'); + }); + it('lets the same user restart a job that is no longer running', () => { + const id = randomUUID(); + createJob(id, 'owner'); + expect(() => createJob(id, 'owner')).toThrow(); + + failJob(id, 'connection reset'); + expect(() => createJob(id, 'another')).toThrow(); + createJob(id, 'owner'); + expect(getJob(id, 'owner')?.status).toBe('running'); + expect(getJob(id, 'owner')?.error).toBeUndefined(); + }); + it('does not allow another user to inspect or cancel a job', async () => { + const id = randomUUID(); + createJob(id, 'owner'); + expect(getJob(id, 'another')).toBeNull(); + expect(await cancelJob(id, 'another')).toBe(false); + expect(getJob(id, 'owner')?.status).toBe('running'); + }); + it('releases stream cancellation closures in every terminal state', async () => { + for (const status of ['done', 'error', 'cancelled']) { + const id = randomUUID(); + createJob(id, 'owner'); + setJobCancellation(id, () => {}); + if (status === 'done') completeJob(id, null); + else if (status === 'error') failJob(id, 'failed'); + else await cancelJob(id, 'owner'); + expect(getJob(id, 'owner')?.cancel).toBeUndefined(); + } + }); +}); + +describe('jobs that settle themselves', () => { + it('refuses to reuse a job ID when restarts are not allowed', () => { + const id = randomUUID(); + createJob(id, 'owner', { allowRestart: false }); + finishJob(id, null); + expect(() => createJob(id, 'owner', { allowRestart: false })).toThrow( + expect.objectContaining({ status: 409 }) + ); + }); + + it('tracks a cancel request separately from the final status', async () => { + const id = randomUUID(); + const cancel = vi.fn(); + createJob(id, 'owner', { allowRestart: false, cancelSettles: false }); + setJobCancellation(id, cancel); + + expect(await cancelJob(id, 'owner')).toBe(true); + expect(cancel).toHaveBeenCalledOnce(); + expect(getJob(id, 'owner')).toMatchObject({ status: 'running', cancelRequested: true }); + + recordJobItem(id, { sourceKey: 'a', status: 'done' }); + finishJob(id, { done: 1 }); + expect(getJob(id, 'owner')).toMatchObject({ + status: 'cancelled', + result: { done: 1 }, + items: [{ sourceKey: 'a', status: 'done' }] + }); + }); + + it('finishes as done without a cancel request', () => { + const id = randomUUID(); + createJob(id, 'owner', { cancelSettles: false }); + finishJob(id, null); + expect(getJob(id, 'owner')?.status).toBe('done'); + }); + + it('records item results in every state', async () => { + const id = randomUUID(); + createJob(id, 'owner'); + await cancelJob(id, 'owner'); + recordJobItem(id, { sourceKey: 'late' }); + expect(getJob(id, 'owner')).toMatchObject({ + status: 'cancelled', + items: [{ sourceKey: 'late' }] + }); + }); +}); + +describe('job expiry', () => { + const MINUTE = 60_000; + + async function freshStore() { + vi.resetModules(); + return import('./job-store.js'); + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps a running job while it keeps reporting progress', async () => { + const store = await freshStore(); + const id = randomUUID(); + store.createJob(id, 'owner'); + for (let minute = 1; minute <= 45; minute++) { + await vi.advanceTimersByTimeAsync(MINUTE); + store.updateJobProgress(id, { completedBytes: minute }); + } + expect(store.getJob(id, 'owner')?.status).toBe('running'); + }); + + it('keeps a running job alive with a heartbeat', async () => { + const store = await freshStore(); + const id = randomUUID(); + store.createJob(id, 'owner'); + for (let minute = 1; minute <= 45; minute++) { + await vi.advanceTimersByTimeAsync(MINUTE); + store.touchJob(id); + } + expect(store.getJob(id, 'owner')?.status).toBe('running'); + }); + + it('expires a running job after 30 minutes without progress', async () => { + const store = await freshStore(); + const id = randomUUID(); + store.createJob(id, 'owner'); + await vi.advanceTimersByTimeAsync(29 * MINUTE); + expect(store.getJob(id, 'owner')).not.toBeNull(); + await vi.advanceTimersByTimeAsync(2 * MINUTE + 1); + expect(store.getJob(id, 'owner')).toBeNull(); + }); + + it('retains a finished job for five minutes counted from completion', async () => { + const store = await freshStore(); + const id = randomUUID(); + store.createJob(id, 'owner'); + await vi.advanceTimersByTimeAsync(10 * MINUTE); + store.completeJob(id, null); + await vi.advanceTimersByTimeAsync(4 * MINUTE); + expect(store.getJob(id, 'owner')?.status).toBe('done'); + await vi.advanceTimersByTimeAsync(2 * MINUTE + 1); + expect(store.getJob(id, 'owner')).toBeNull(); + }); +}); diff --git a/src/lib/server/storage/job-store.ts b/src/lib/server/storage/job-store.ts new file mode 100644 index 00000000..ce1376de --- /dev/null +++ b/src/lib/server/storage/job-store.ts @@ -0,0 +1,206 @@ +import { logger } from '$lib/server/logging'; +import { error } from '@sveltejs/kit'; +import { z } from 'zod'; + +const log = logger.child({ module: 'job-store' }); + +export interface JobProgress { + completedCount: number; + completedBytes: number; + currentFileName?: string; +} + +interface JobEntry { + userId: string; + status: 'running' | 'done' | 'error' | 'cancelled'; + result: T | null; + error?: string; + createdAt: number; + /** Last progress update while running; time of completion once finished. */ + updatedAt: number; + progress: JobProgress; + /** Per-item results, recorded in every state, including after a cancel. */ + items: unknown[]; + /** A cancel was requested; for a job that settles itself the status is still `running`. */ + cancelRequested: boolean; + /** Whether a cancel sets the final status, or the job settles itself via {@link finishJob}. */ + cancelSettles: boolean; + cancel?: () => Promise | void; +} + +export interface CreateJobOptions { + /** Whether the same user may reuse the ID of a job that is no longer running. Default `true`. */ + allowRestart?: boolean; + /** + * Whether a cancel immediately marks the job `cancelled`. Default `true`. A job + * that still has work to report after a cancel (a transfer finishing its + * deletes) passes `false` and calls {@link finishJob} when it really ends. + */ + cancelSettles?: boolean; +} + +const store = new Map>(); + +// A running job expires only after this long without progress. Transfers that +// report no progress until they finish (e.g. a cross-provider copy) can still +// expire if they take longer than this. +const RUNNING_TTL = 30 * 60 * 1000; +// A finished job is kept this long after it finished, not after it started. +const DONE_TTL = 5 * 60 * 1000; +const CLEANUP_INTERVAL = 60_000; + +let cleanupTimer: ReturnType | null = null; + +function startCleanup(): void { + if (cleanupTimer) return; + cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [id, entry] of store) { + const ttl = entry.status === 'running' ? RUNNING_TTL : DONE_TTL; + if (now - entry.updatedAt > ttl) { + store.delete(id); + log.trace({ job_id: id, status: entry.status }, 'cleaned up expired job'); + } + } + }, CLEANUP_INTERVAL); + cleanupTimer.unref(); +} + +startCleanup(); + +/** + * Create a new job entry. By default the same user may reuse the ID of a job + * that is no longer running, so a browser retry of a failed download (same + * URL, same job ID) can start again. With `allowRestart: false` every ID can + * be used once, so a retried request never runs again. + */ +export function createJob(id: string, userId: string, options: CreateJobOptions = {}): void { + const { allowRestart = true, cancelSettles = true } = options; + if (!z.uuid().safeParse(id).success) throw error(400, 'Invalid job ID'); + const existing = store.get(id); + if (existing && (!allowRestart || existing.userId !== userId || existing.status === 'running')) { + throw error(409, 'Job already exists'); + } + if (existing) log.debug({ job_id: id, previous_status: existing.status }, 'restarting job'); + store.set(id, { + userId, + status: 'running', + result: null, + createdAt: Date.now(), + updatedAt: Date.now(), + progress: { completedCount: 0, completedBytes: 0 }, + items: [], + cancelRequested: false, + cancelSettles + }); +} + +/** Attach a response stream's cancellation callback to a running job. */ +export function setJobCancellation(id: string, cancel: () => Promise | void): void { + const entry = store.get(id); + if (entry?.status === 'running') entry.cancel = cancel; +} + +/** + * Cancel a running job and its active work. A job created with + * `cancelSettles: false` stays `running` until it calls {@link finishJob}. + */ +export async function cancelJob(id: string, userId: string): Promise { + const entry = store.get(id); + if (!entry || entry.userId !== userId || entry.status !== 'running') return false; + entry.cancelRequested = true; + entry.updatedAt = Date.now(); + if (entry.cancelSettles) entry.status = 'cancelled'; + const cancel = entry.cancel; + entry.cancel = undefined; + await cancel?.(); + log.trace({ job_id: id, settled: entry.cancelSettles }, 'job cancel requested'); + return true; +} + +/** Keep a running job from expiring while it works without reporting progress. */ +export function touchJob(id: string): void { + const entry = store.get(id); + if (entry?.status === 'running') entry.updatedAt = Date.now(); +} + +/** Record the result of one item of a job, in any state. */ +export function recordJobItem(id: string, item: unknown): void { + const entry = store.get(id); + if (!entry) return; + entry.items.push(item); + entry.updatedAt = Date.now(); +} + +/** + * Settle a job created with `cancelSettles: false`: `cancelled` if a cancel + * was requested, otherwise `done`. Stores the result either way. + */ +export function finishJob(id: string, result: T): void { + const entry = store.get(id); + if (!entry || entry.status !== 'running') return; + entry.status = entry.cancelRequested ? 'cancelled' : 'done'; + entry.updatedAt = Date.now(); + entry.cancel = undefined; + (entry as JobEntry).result = result; + log.trace({ job_id: id, status: entry.status }, 'job finished'); +} + +/** + * Update the progress of a running job. + */ +export function updateJobProgress(id: string, progress: Partial): void { + const entry = store.get(id); + if (!entry || entry.status !== 'running') return; + entry.updatedAt = Date.now(); + if (progress.completedCount !== undefined) + entry.progress.completedCount = progress.completedCount; + if (progress.completedBytes !== undefined) + entry.progress.completedBytes = progress.completedBytes; + if (progress.currentFileName !== undefined) + entry.progress.currentFileName = progress.currentFileName; +} + +/** + * Mark a job as completed and store its result. + */ +export function completeJob(id: string, result: T): void { + const entry = store.get(id); + if (!entry || entry.status !== 'running') return; + entry.status = 'done' as const; + entry.updatedAt = Date.now(); + entry.cancel = undefined; + (entry as JobEntry).result = result; + log.trace({ job_id: id }, 'job completed'); +} + +/** + * Mark a job as failed. + */ +export function failJob(id: string, error: string): void { + const entry = store.get(id); + if (!entry || entry.status !== 'running') return; + entry.status = 'error' as const; + entry.updatedAt = Date.now(); + entry.cancel = undefined; + entry.error = error; + log.trace({ job_id: id, error }, 'job failed'); +} + +/** Mark a job cancelled when the browser abandons its native download. */ +export function cancelJobFromStream(id: string): void { + const entry = store.get(id); + if (!entry || entry.status !== 'running') return; + entry.status = 'cancelled'; + entry.updatedAt = Date.now(); + entry.cancel = undefined; + log.trace({ job_id: id }, 'job cancelled by download stream'); +} + +/** + * Get the current state of a job. Returns null if not found (expired or never existed). + */ +export function getJob(id: string, userId: string): JobEntry | null { + const entry = store.get(id); + return entry?.userId === userId ? (entry as JobEntry) : null; +} diff --git a/src/lib/server/storage/preview/cache.ts b/src/lib/server/storage/preview/cache.ts new file mode 100644 index 00000000..91d9c6c6 --- /dev/null +++ b/src/lib/server/storage/preview/cache.ts @@ -0,0 +1,64 @@ +const DEFAULT_TTL_MS = 5 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 60 * 1000; + +interface CacheEntry { + value: T; + lastAccessed: number; +} + +/** In-memory cache that expires inactive entries without retaining request state. */ +export class ExpiringCache { + private readonly entries = new Map>(); + private readonly timer: ReturnType; + + constructor(private readonly ttlMs = DEFAULT_TTL_MS) { + this.timer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS); + this.timer.unref(); + } + + get(key: string): T | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + if (Date.now() - entry.lastAccessed > this.ttlMs) { + this.entries.delete(key); + return undefined; + } + entry.lastAccessed = Date.now(); + return entry.value; + } + + set(key: string, value: T): void { + this.entries.set(key, { value, lastAccessed: Date.now() }); + } + + private cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.entries) { + if (now - entry.lastAccessed > this.ttlMs) this.entries.delete(key); + } + } +} + +/** The parts of an object's metadata that identify its version. */ +export interface ObjectVersionInfo { + size: number; + etag?: string; + lastModified?: Date; +} + +/** + * Token that changes whenever the object is overwritten: the ETag, or size and + * last-modified time for backends without one. `undefined` when neither is + * known, in which case the object must not be cached. + */ +export function objectVersion(meta: ObjectVersionInfo): string | undefined { + if (meta.etag) return meta.etag; + // Some providers report a missing Last-Modified as the epoch. + if (meta.lastModified && meta.lastModified.getTime() > 0) + return `${meta.size}:${meta.lastModified.getTime()}`; + return undefined; +} + +export function previewCacheKey(connectionId: string, bucket: string, key: string): string { + return `${connectionId}:${bucket}:${key}`; +} diff --git a/src/lib/server/storage/preview/csv.test.ts b/src/lib/server/storage/preview/csv.test.ts new file mode 100644 index 00000000..7a18f034 --- /dev/null +++ b/src/lib/server/storage/preview/csv.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { StorageProvider } from '$lib/server/storage/provider.js'; +import { createMockLogger } from '$lib/test-utils/mock-logger.js'; + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + +// Use a small textPreviewBytes to force predictable chunk boundaries +vi.mock('$lib/server/feature-flags', () => ({ + textPreviewBytes: 11, + infiniteScrollEnabled: true +})); + +import { getCsvPreview } from './csv.js'; + +const mockLog = createMockLogger(); + +function makeProvider( + getObjectRangeImpl: (key: string, start: number, end: number) => Promise +): StorageProvider { + return { + getObjectRange: vi.fn(getObjectRangeImpl), + getObject: vi.fn(), + getMetadata: vi.fn(), + listContainers: vi.fn(), + listObjects: vi.fn(), + exists: vi.fn(), + putObject: vi.fn(), + deleteObjects: vi.fn(), + listAllKeysProgressively: vi.fn(), + getBucketVersioning: vi.fn(), + getBucketLifecycleRules: vi.fn(), + getBucketTags: vi.fn(), + getBucketAcl: vi.fn(), + copyObject: vi.fn() + } as unknown as StorageProvider; +} + +async function readNdjsonResponse( + res: Response +): Promise<{ headers: string[]; rows: string[][]; totalRows: number }> { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const result: { headers: string[]; rows: string[][]; totalRows: number } = { + headers: [], + rows: [], + totalRows: 0 + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.t === 'h') { + result.headers = msg.h; + result.totalRows = msg.tr; + } else if (msg.t === 'r') { + result.rows = msg.v; + } + } + } + return result; +} + +function sliceProvider(content: string) { + const enc = new TextEncoder(); + const bytes = enc.encode(content); + const provider = makeProvider(async (_key: string, start: number, end: number) => { + const slice = bytes.slice(start, end + 1); + return new ReadableStream({ + start(controller) { + controller.enqueue(slice); + controller.close(); + } + }); + }); + vi.mocked(provider.getMetadata).mockResolvedValue({ + size: bytes.length, + etag: content + } as Awaited>); + return provider; +} + +describe('getCsvPreview', () => { + it('uses UTF-8 byte ranges for non-ASCII rows across chunks', async () => { + const content = 'h\nä\n中\n😀\nfin\n'; + const provider = sliceProvider(content); + await getCsvPreview(provider, 'unicode', 0, 250, 18, mockLog, false); + const result = await getCsvPreview(provider, 'unicode', 1, 250, 18, mockLog, true); + expect((await readNdjsonResponse(result)).rows).toEqual([['中'], ['😀'], ['fin']]); + }); + it('invalidates the index when an equal-size object is replaced', async () => { + const first = sliceProvider('h\na\nb\n'); + await getCsvPreview(first, 'replacement', 0, 250, 6, mockLog, true); + const second = sliceProvider('h\nabc\n'); + const response = await getCsvPreview(second, 'replacement', 0, 250, 6, mockLog, true); + expect((await readNdjsonResponse(response)).rows).toEqual([['abc']]); + }); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns rows when the whole file fits in one chunk', async () => { + const fileContent = 'h\na\nb\nc\n'; + const provider = sliceProvider(fileContent); + + const res = await getCsvPreview(provider, 'small', 0, 250, fileContent.length, mockLog, true); + + expect(res.headers.get('X-Preview-Total-Rows')).toBe('3'); + const body = await readNdjsonResponse(res); + expect(body.rows).toEqual([['a'], ['b'], ['c']]); + expect(body.totalRows).toBe(3); + }); + + it('does not create phantom rows at chunk boundary mid-line', async () => { + // Bytes: h(0) \n(1) a(2) b(3) c(4) \n(5) d(6) e(7) f(8) \n(9) g(10) h(11) i(12) + // \n(13) j(14) k(15) l(16) \n(17) m(18) n(19) o(20) \n(21) + // textPreviewBytes=11, range 0..10 = "h\nabc\ndef\ng" (ends mid-line "g") + const fileContent = 'h\nabc\ndef\nghi\njkl\nmno\n'; + const fileSize = fileContent.length; // 22 + const provider = sliceProvider(fileContent); + + // First call loads chunk 1 (bytes 0..10) → rows "abc", "def". (Incomplete "g" deferred.) + const res1 = await getCsvPreview(provider, 'boundary', 0, 250, fileSize, mockLog, true); + expect(res1.headers.get('X-Preview-Total-Rows')).toBe('2'); + + // Second call triggers chunk 2 (bytes 10..20) → rows "ghi", "jkl". (Incomplete "mno" deferred.) + const res2 = await getCsvPreview(provider, 'boundary', 2, 250, fileSize, mockLog, true); + expect(res2.headers.get('X-Preview-Total-Rows')).toBe('4'); + + // Third call triggers chunk 3 (bytes 18..21) → row "mno" + const res3 = await getCsvPreview(provider, 'boundary', 4, 250, fileSize, mockLog, true); + expect(res3.headers.get('X-Preview-Total-Rows')).toBe('5'); + + // Now read the last page - should get 'mno' as complete row, not split + const body3 = await readNdjsonResponse(res3); + expect(body3.rows).toEqual([['mno']]); + expect(body3.totalRows).toBe(5); + }); + + it('rereads a file of the same size once its last-modified time changes', async () => { + const file = { content: 'h\nabc\n', lastModified: new Date('2026-10-15T10:00:00Z') }; + const provider = makeProvider(async (_key: string, start: number, end: number) => { + const slice = new TextEncoder().encode(file.content).slice(start, end + 1); + return new ReadableStream({ + start(controller) { + controller.enqueue(slice); + controller.close(); + } + }); + }); + vi.mocked(provider.getMetadata).mockImplementation( + async () => + ({ + size: file.content.length, + lastModified: file.lastModified + }) as Awaited> + ); + const size = file.content.length; + + const first = await readNdjsonResponse( + await getCsvPreview(provider, 'rewritten', 0, 250, size, mockLog, true) + ); + expect(first.rows).toEqual([['abc']]); + + file.content = 'h\nxyz\n'; + file.lastModified = new Date('2026-10-15T10:05:00Z'); + const second = await readNdjsonResponse( + await getCsvPreview(provider, 'rewritten', 0, 250, size, mockLog, true) + ); + expect(second.rows).toEqual([['xyz']]); + }); + + it('readRows returns correct rows across a chunk boundary', async () => { + // Same file as above + const fileContent = 'h\nabc\ndef\nghi\njkl\nmno\n'; + const fileSize = fileContent.length; + const provider = sliceProvider(fileContent); + + // Load cache with first call + await getCsvPreview(provider, 'read-test', 0, 250, fileSize, mockLog, false); + + // Request data that straddles the chunk boundary (after chunk 1 loaded "abc","def") + // chunk 2 needs to be triggered, which loads "ghi","jkl" (and defers "mno") + const res = await getCsvPreview(provider, 'read-test', 1, 3, fileSize, mockLog, true); + const body = await readNdjsonResponse(res); + // Should get "def" (index 1), "ghi" (index 2), "jkl" (index 3) + expect(body.rows).toEqual([['def'], ['ghi'], ['jkl']]); + }); + + it('does not create phantom rows across multiple chunk boundaries', async () => { + // File with 10 rows, each row 5 bytes ("rowN\n"), plus header "h\n" = 2 bytes + // Total: 2 + 10*5 = 52 bytes. With textPreviewBytes=11, need ~6 extendCache calls. + const fileContent = 'h\nrow0\nrow1\nrow2\nrow3\nrow4\nrow5\nrow6\nrow7\nrow8\nrow9\n'; + const fileSize = fileContent.length; + const provider = sliceProvider(fileContent); + + // Load all data by making enough requests with increasing offsets + // Each call triggers one chunk read. We load header first, then progressively + // request data at higher offsets to force more chunks. + for (let offset = 0; offset < 15; offset += 3) { + await getCsvPreview(provider, 'multi-boundary', offset, 250, fileSize, mockLog, false); + } + + // Now request all data + const res = await getCsvPreview(provider, 'multi-boundary', 0, 250, fileSize, mockLog, true); + const body = await readNdjsonResponse(res); + expect(body.totalRows).toBe(10); + expect(body.rows).toHaveLength(10); + expect(body.rows[0]).toEqual(['row0']); + expect(body.rows[9]).toEqual(['row9']); + // Verify no row is garbled (e.g. "w1" from a split "row1") + expect(body.rows[1]).toEqual(['row1']); + expect(body.rows[2]).toEqual(['row2']); + }); + + it('includes the last line when the file has no trailing newline', async () => { + // E2E scenario: file without trailing \n on the last line. + // The last line MUST be included even though text doesn't end with \n. + const fileContent = 'h\na\nb'; // 5 bytes, fits in one chunk (textPreviewBytes=11) + const provider = sliceProvider(fileContent); + + const res = await getCsvPreview( + provider, + 'no-trailing-newline', + 0, + 250, + fileContent.length, + mockLog, + true + ); + + expect(res.headers.get('X-Preview-Total-Rows')).toBe('2'); + const body = await readNdjsonResponse(res); + expect(body.rows).toHaveLength(2); + expect(body.rows).toEqual([['a'], ['b']]); + }); +}); diff --git a/src/lib/server/storage/preview/csv.ts b/src/lib/server/storage/preview/csv.ts new file mode 100644 index 00000000..1b969063 --- /dev/null +++ b/src/lib/server/storage/preview/csv.ts @@ -0,0 +1,307 @@ +import type pino from 'pino'; +import type { StorageProvider } from '$lib/server/storage/provider.js'; +import { textPreviewBytes, infiniteScrollEnabled } from '$lib/server/feature-flags.js'; +import { logger } from '$lib/server/logging'; +import { ExpiringCache, objectVersion, previewCacheKey } from './cache.js'; + +const fallbackLog = logger.child({ module: 'csv-preview' }); + +// ── In-memory line-offset cache for S3 cost optimisation ── + +interface CsvCacheEntry { + headers: string[]; + /** Byte offset of each data line's first byte (0-based relative to file start, after the header line) */ + lineOffsets: number[]; + /** Total bytes consumed from S3 so far */ + bytesRead: number; + /** Total file size */ + totalSize: number; + /** `objectVersion` of the file the offsets were read from. */ + version?: string; + lastAccessed: number; +} + +const csvPreviewCache = new ExpiringCache(); + +// ── Helpers ── + +async function streamToArrayBuffer(stream: ReadableStream): Promise { + const chunks: Uint8Array[] = []; + const reader = stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result.buffer; +} + +/** Parse a single CSV row into fields, handling quoted values. */ +function parseCsvRow(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (inQuotes) { + if (char === '"') { + if (i + 1 < line.length && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = false; + } + } else { + current += char; + } + } else { + if (char === '"') { + inQuotes = true; + } else if (char === ',') { + result.push(current); + current = ''; + } else if (char === '\r') { + // skip carriage return + } else { + current += char; + } + } + } + result.push(current); + return result; +} + +/** + * Read a portion of the file from S3 and parse lines, extending the cache entry. + * Returns the updated entry. + */ +async function extendCache( + provider: StorageProvider, + key: string, + entry: CsvCacheEntry, + targetRow: number +): Promise { + // Already have enough data + if (entry.lineOffsets.length >= targetRow) return entry; + + // Determine how many more bytes to read + const bytesToRead = Math.min(entry.totalSize - entry.bytesRead, textPreviewBytes); + if (bytesToRead <= 0) return entry; + + const rangeEnd = entry.bytesRead + bytesToRead - 1; + const stream = await provider.getObjectRange(key, entry.bytesRead, rangeEnd); + const buffer = await streamToArrayBuffer(stream); + const bytes = new Uint8Array(buffer); + const initialOffset = entry.bytesRead; + let lineStart = 0; + const consumeLine = (end: number, next: number) => { + if (entry.headers.length === 0) { + entry.headers = parseCsvRow( + new TextDecoder().decode(bytes.subarray(lineStart, end)).replace(/\r$/, '') + ); + } else { + entry.lineOffsets.push(initialOffset + lineStart); + } + lineStart = next; + entry.bytesRead = initialOffset + next; + }; + for (let index = 0; index < bytes.length; index++) { + if (bytes[index] === 10) consumeLine(index, index + 1); + } + if (initialOffset + bytes.length >= entry.totalSize && lineStart < bytes.length) { + consumeLine(bytes.length, bytes.length); + } + return entry; +} + +/** Read exact byte range for a set of rows and return parsed values. */ +async function readRows( + provider: StorageProvider, + key: string, + entry: CsvCacheEntry, + startOffset: number, + endOffset: number +): Promise { + const startByte = entry.lineOffsets[startOffset]; + const endByte = + endOffset < entry.lineOffsets.length ? entry.lineOffsets[endOffset] - 1 : entry.bytesRead - 1; + + if (startByte === undefined || startByte < 0) return []; + + const stream = await provider.getObjectRange(key, startByte, Math.max(startByte, endByte)); + const buffer = await streamToArrayBuffer(stream); + const text = new TextDecoder('utf-8', { fatal: false }).decode(buffer); + + const lines = text.split('\n'); + const rows: string[][] = []; + for (const line of lines) { + const trimmed = line.replace(/\r$/, ''); + if (trimmed.length > 0 || rows.length < lines.length - 1) { + rows.push(parseCsvRow(trimmed)); + } + } + return rows; +} + +// ── Main entry point ── + +/** + * Get a CSV preview with optional row-based data pagination. + * + * When `includeData` is true, returns an NDJSON stream with: + * `{t:'h', h:[headers]}` header message + * `{t:'r', v:[[row1], [row2], ...]}` row data + * `{t:'d'}` done + * + * When `includeData` is false, returns only the header message. + * + * Uses an in-memory line-offset cache so subsequent range requests read + * only the exact bytes needed (no redundant S3 reads). + */ +export async function getCsvPreview( + provider: StorageProvider, + key: string, + offset = 0, + limit = 250, + totalSize: number, + requestLog: pino.Logger = fallbackLog, + includeData = false, + bucket = '', + connectionId = '' +): Promise { + const log = requestLog.child({ module: 'csv-preview' }); + const metadata = await provider.getMetadata(key); + totalSize = metadata.size; + + if (totalSize === 0) { + return new Response( + JSON.stringify({ t: 'h', h: [], tr: 0 }) + '\n' + JSON.stringify({ t: 'd' }) + '\n', + { + headers: { + 'Content-Type': 'application/x-ndjson', + 'X-Preview-Format': 'csv', + 'X-Preview-Renderable': 'true', + 'X-Preview-Infinite-Scroll': String(infiniteScrollEnabled), + 'X-Preview-Total-Size': '0', + 'X-Preview-Total-Rows': '0', + 'X-Preview-Offset': String(offset), + 'Cache-Control': 'no-store' + } + } + ); + } + + const cacheKey = previewCacheKey(connectionId, bucket, key); + let entry = csvPreviewCache.get(cacheKey); + const now = Date.now(); + + const version = objectVersion(metadata); + + // Without a version the file cannot be told apart from an overwritten one, + // so the offsets are only reused for this request. + if ( + !entry || + version === undefined || + entry.totalSize !== totalSize || + entry.version !== version + ) { + entry = { + headers: [], + lineOffsets: [], + bytesRead: 0, + totalSize, + version, + lastAccessed: now + }; + csvPreviewCache.set(cacheKey, entry); + } + + entry.lastAccessed = now; + + const dataEndRow = offset + limit; + + // Extend cache if needed + await extendCache(provider, key, entry, dataEndRow); + + if (!includeData) { + return new Response( + JSON.stringify({ t: 'h', h: entry.headers, tr: entry.lineOffsets.length }) + + '\n' + + JSON.stringify({ t: 'd' }) + + '\n', + { + headers: { + 'Content-Type': 'application/x-ndjson', + 'X-Preview-Format': 'csv', + 'X-Preview-Renderable': 'true', + 'X-Preview-Infinite-Scroll': String(infiniteScrollEnabled), + 'X-Preview-Total-Size': String(totalSize), + 'X-Preview-Total-Rows': String(entry.lineOffsets.length), + 'X-Preview-Offset': String(offset), + 'Cache-Control': 'no-store' + } + } + ); + } + + const truncated = entry.lineOffsets.length < totalSize || dataEndRow < entry.lineOffsets.length; + const actualEndRow = Math.min(dataEndRow, entry.lineOffsets.length); + + let rows: string[][]; + if (offset < entry.lineOffsets.length) { + rows = await readRows(provider, key, entry, offset, actualEndRow); + } else { + rows = []; + } + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + JSON.stringify({ + t: 'h', + h: entry.headers, + tr: entry.lineOffsets.length + }) + '\n' + ) + ); + + if (rows.length > 0) { + controller.enqueue(encoder.encode(JSON.stringify({ t: 'r', v: rows }) + '\n')); + } + + controller.enqueue(encoder.encode(JSON.stringify({ t: 'd' }) + '\n')); + controller.close(); + }, + cancel() { + // client disconnected + } + }); + + log.info( + { key, offset, limit, rows_returned: rows.length, total_file_size: totalSize, truncated }, + 'csv preview chunk' + ); + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'X-Preview-Format': 'csv', + 'X-Preview-Renderable': 'true', + 'X-Preview-Infinite-Scroll': String(infiniteScrollEnabled), + 'X-Preview-Truncated': String(truncated), + 'X-Preview-Total-Size': String(totalSize), + 'X-Preview-Total-Rows': String(entry.lineOffsets.length), + 'X-Preview-Offset': String(offset), + 'Cache-Control': 'no-store' + } + }); +} diff --git a/src/lib/server/storage/preview/parquet.test.ts b/src/lib/server/storage/preview/parquet.test.ts new file mode 100644 index 00000000..3720144c --- /dev/null +++ b/src/lib/server/storage/preview/parquet.test.ts @@ -0,0 +1,814 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { StorageProvider } from '$lib/server/storage/provider.js'; +import { createMockLogger } from '$lib/test-utils/mock-logger.js'; + +const mockParquetMetadataAsync = vi.fn(); +const mockParquetRead = vi.fn(); +const mockParquetSchema = vi.fn(); + +vi.mock('hyparquet', () => ({ + parquetMetadataAsync: (...args: unknown[]) => mockParquetMetadataAsync(...args), + parquetRead: (...args: unknown[]) => mockParquetRead(...args), + parquetSchema: (...args: unknown[]) => mockParquetSchema(...args) +})); + +vi.mock('hyparquet-compressors', () => ({ + compressors: { UNCOMPRESSED: vi.fn() } +})); + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + +vi.mock('$lib/server/feature-flags', () => ({ + parquetDisallowedCompression: [{ codec: 'GZIP', requireOffsetIndex: true }], + storageBrowserEnabled: true, + completionEnabled: true, + filePreviewRows: 250, + textPreviewBytes: 256 * 1024, + imagePreviewBytes: 5 * 1024 * 1024, + pdfPreviewBytes: 25 * 1024 * 1024, + infiniteScrollEnabled: true +})); + +import { getParquetPreview } from './parquet.js'; + +const mockLog = createMockLogger(); + +function makeProvider(overrides: Partial = {}): StorageProvider { + return { + listContainers: vi.fn(), + listObjects: vi.fn(), + getObject: vi.fn(), + getObjectRange: vi.fn(), + getMetadata: vi.fn(), + exists: vi.fn(), + putObject: vi.fn(), + putObjectIfMatch: vi.fn(), + deleteObjects: vi.fn(), + prefixExists: vi.fn(), + listAllKeysProgressively: vi.fn(), + search: vi.fn(), + getBucketVersioning: vi.fn(), + getBucketLifecycleRules: vi.fn(), + getBucketTags: vi.fn(), + getBucketAcl: vi.fn(), + copyObject: vi.fn(), + ...overrides + }; +} + +function makeStream(bytes?: Uint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes ?? new Uint8Array([0, 0])); + controller.close(); + } + }); +} + +/** + * Read an NDJSON streaming response into a plain object, simulating the client-side stream parser. + * Returns the first "h" (headers) message followed by accumulated column data and the final message. + */ +async function readNdjsonResponse(res: Response): Promise<{ + headers: string[]; + rows: unknown[][]; + totalRows: number; + truncated: boolean; + error?: string; + schema?: Array<{ name: string; type: string }>; + metadata?: { + rowGroups: number; + compressionCodecs: string[]; + hasOffsetIndex: boolean; + hasColumnIndex: boolean; + createdBy: string | null; + version: number; + }; +}> { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let resultHeaders: string[] = []; + const rows: unknown[][] = []; + let resultTotalRows = 0; + let error: string | undefined; + let schema: Array<{ name: string; type: string }> | undefined; + let metadata: + | { + rowGroups: number; + compressionCodecs: string[]; + hasOffsetIndex: boolean; + hasColumnIndex: boolean; + createdBy: string | null; + version: number; + } + | undefined; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + + if (msg.t === 'h') { + resultHeaders = msg.h; + resultTotalRows = msg.tr; + schema = msg.s; + metadata = msg.m; + } else if (msg.t === 'c') { + const colIdx = resultHeaders.indexOf(msg.n); + if (colIdx < 0) continue; + const values = msg.v as unknown[]; + while (rows.length < values.length) { + rows.push(new Array(resultHeaders.length).fill(undefined)); + } + for (let i = 0; i < values.length; i++) { + if (!rows[i]) rows[i] = new Array(resultHeaders.length).fill(undefined); + + rows[i][colIdx] = values[i]; + } + } else if (msg.t === 'e') { + error = 'Server error'; + } + } + } + + return { + headers: resultHeaders, + rows, + totalRows: resultTotalRows, + truncated: res.headers.get('X-Preview-Truncated') === 'true', + error, + schema, + metadata + }; +} + +/** + * Invoke the onChunk / onComplete callbacks that parquetRead would normally call, + * to simulate streaming column data. + */ +function invokeParquetReadCallbacks( + args: unknown[], + columnDataMap: Record +): void { + const options = args[0] as { + onChunk?: (chunk: { columnName: string; columnData: unknown[] }) => void; + onComplete?: () => void; + }; + + // Simulate column data streaming: fire onChunk for each column. + // NOT passing rowStart/rowEnd so the trimming logic in parquet.ts uses + // undefined → NaN → false comparison → passes through full columnData. + for (const [colName, values] of Object.entries(columnDataMap)) { + if (options.onChunk) { + options.onChunk({ columnName: colName, columnData: values }); + } + } + + // Then fire onComplete + if (options.onComplete) { + options.onComplete(); + } +} + +/** + * Return a resolved promise mimicking parquetRead's async behaviour. + */ +function resolvedMockParquetRead(args: unknown[], columnDataMap: Record) { + invokeParquetReadCallbacks(args, columnDataMap); + return Promise.resolve(); +} + +describe('getParquetPreview', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockParquetSchema.mockReturnValue({ + children: [ + { element: { name: 'id', type: 'INT64' } }, + { + element: { + name: 'name', + type: 'BYTE_ARRAY', + converted_type: 'UTF8', + logical_type: { type: 'STRING' } + } + }, + { element: { name: 'active', type: 'BOOLEAN' } } + ] + }); + }); + + afterEach(() => { + vi.resetModules(); + }); + + it('returns empty response for zero-size parquet file', async () => { + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 0, contentType: 'application/x-parquet' }) + }); + + const res = await getParquetPreview(provider, 'empty.parquet', 0, 250, mockLog); + + expect(res.status).toBe(200); + expect(res.headers.get('X-Preview-Format')).toBe('parquet'); + expect(res.headers.get('X-Preview-Renderable')).toBe('true'); + expect(res.headers.get('X-Preview-Total-Rows')).toBe('0'); + const body = await readNdjsonResponse(res); + expect(body.headers).toEqual([]); + expect(body.rows).toEqual([]); + expect(body.totalRows).toBe(0); + expect(body.schema).toEqual([]); + expect(body.metadata).toBeDefined(); + expect(body.metadata?.rowGroups).toBe(0); + expect(mockParquetMetadataAsync).not.toHaveBeenCalled(); + }); + + it('parses parquet metadata and returns headers and rows with schema and metadata', async () => { + mockParquetMetadataAsync.mockResolvedValue({ + num_rows: 2n, + row_groups: [], + created_by: 'test', + version: 1 + }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { + id: [1n, 2n], + name: ['Alice', 'Bob'], + active: [true, false] + }) + ); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview(provider, 'data.parquet', 0, 250, mockLog, undefined, true); + + expect(res.status).toBe(200); + expect(res.headers.get('X-Preview-Format')).toBe('parquet'); + expect(res.headers.get('X-Preview-Renderable')).toBe('true'); + expect(res.headers.get('X-Preview-Total-Rows')).toBe('2'); + expect(res.headers.get('X-Preview-Total-Size')).toBe('1024'); + expect(res.headers.get('X-Preview-Offset')).toBe('0'); + + const body = await readNdjsonResponse(res); + expect(body.headers).toEqual(['id', 'name', 'active']); + expect(body.rows).toEqual([ + ['1', 'Alice', true], + ['2', 'Bob', false] + ]); + expect(body.totalRows).toBe(2); + + // Schema info + expect(body.schema).toBeDefined(); + expect(body.schema).toHaveLength(3); + expect(body.schema![0]).toMatchObject({ name: 'id', type: 'int64' }); + expect(body.schema![1]).toMatchObject({ name: 'name', type: 'string' }); + expect(body.schema![2]).toMatchObject({ name: 'active', type: 'boolean' }); + + // Metadata + expect(body.metadata).toBeDefined(); + expect(body.metadata?.rowGroups).toBe(0); + expect(body.metadata?.version).toBe(1); + }); + + it('handles pagination with offset and limit', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 1000n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => { + const rows = Array.from({ length: 50 }, (_, i) => BigInt(500 + i)); + const names = Array.from({ length: 50 }, (_, i) => `User-${500 + i}`); + return resolvedMockParquetRead(args, { + id: rows, + name: names + }); + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 10240, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'large.parquet', + 500, + 50, + mockLog, + undefined, + true + ); + + expect(res.headers.get('X-Preview-Offset')).toBe('500'); + + const body = await readNdjsonResponse(res); + expect(body.rows).toHaveLength(50); + expect(body.totalRows).toBe(1000); + expect(body.rows[0][1]).toBe('User-500'); + + expect(mockParquetRead).toHaveBeenCalledWith( + expect.objectContaining({ + rowStart: 500, + rowEnd: 550 + }) + ); + }); + + it('returns empty rows when offset exceeds total rows', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 100n, row_groups: [] }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview(provider, 'small.parquet', 200, 250, mockLog); + + const body = await readNdjsonResponse(res); + expect(body.rows).toHaveLength(0); + expect(body.totalRows).toBe(100); + expect(body.truncated).toBe(false); + }); + + it('returns truncated false for final page', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 60n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => { + const data = Array.from({ length: 10 }, (_, i) => BigInt(50 + i)); + return resolvedMockParquetRead(args, { id: data }); + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'final.parquet', + 50, + 250, + mockLog, + undefined, + true + ); + + expect(res.headers.get('X-Preview-Truncated')).toBe('false'); + const body = await readNdjsonResponse(res); + expect(body.rows).toHaveLength(10); + }); + + it('serialises BigInt values as strings', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 1n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { large_id: [9007199254740993n] }) + ); + mockParquetSchema.mockReturnValue({ + children: [{ element: { name: 'large_id' } }] + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'bigint.parquet', + 0, + 250, + mockLog, + undefined, + true + ); + const body = await readNdjsonResponse(res); + expect(body.rows[0][0]).toBe('9007199254740993'); + }); + + it('serialises Date values as ISO strings', async () => { + const date = new Date('2025-06-15T10:30:00.000Z'); + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 1n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { created_at: [date] }) + ); + mockParquetSchema.mockReturnValue({ + children: [{ element: { name: 'created_at' } }] + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'dates.parquet', + 0, + 250, + mockLog, + undefined, + true + ); + const body = await readNdjsonResponse(res); + expect(body.rows[0][0]).toBe('2025-06-15T10:30:00.000Z'); + }); + + it('serialises Uint8Array values as comma-separated numbers', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 1n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { blob: [new Uint8Array([1, 2, 3, 255])] }) + ); + mockParquetSchema.mockReturnValue({ + children: [{ element: { name: 'blob' } }] + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview(provider, 'blob.parquet', 0, 250, mockLog, undefined, true); + const body = await readNdjsonResponse(res); + expect(body.rows[0][0]).toBe('1,2,3,255'); + }); + + it('serialises nested objects as JSON strings', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 1n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { nested: [{ foo: 'bar', num: 42 }] }) + ); + mockParquetSchema.mockReturnValue({ + children: [{ element: { name: 'nested' } }] + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'nested.parquet', + 0, + 250, + mockLog, + undefined, + true + ); + const body = await readNdjsonResponse(res); + expect(body.rows[0][0]).toBe(JSON.stringify({ foo: 'bar', num: 42 })); + }); + + it('handles null and undefined values', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 2n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { + a: ['x', 'y'], + b: [null, null], + c: [undefined, 'z'] + }) + ); + mockParquetSchema.mockReturnValue({ + children: [{ element: { name: 'a' } }, { element: { name: 'b' } }, { element: { name: 'c' } }] + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'nulls.parquet', + 0, + 250, + mockLog, + undefined, + true + ); + const body = await readNdjsonResponse(res); + expect(body.rows[0]).toEqual(['x', null, null]); + expect(body.rows[1]).toEqual(['y', null, 'z']); + }); + + it('handles boolean and number values directly', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 1n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { + flag: [true], + score: [98.5], + count: [42] + }) + ); + mockParquetSchema.mockReturnValue({ + children: [ + { element: { name: 'flag' } }, + { element: { name: 'score' } }, + { element: { name: 'count' } } + ] + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'values.parquet', + 0, + 250, + mockLog, + undefined, + true + ); + const body = await readNdjsonResponse(res); + expect(body.rows[0]).toEqual([true, 98.5, 42]); + }); + + it('returns empty rows and logs error when parquet parsing fails', async () => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 1n, row_groups: [] }); + mockParquetRead.mockRejectedValue(new Error('Corrupt parquet data')); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'corrupt.parquet', + 0, + 250, + mockLog, + undefined, + true + ); + + expect(res.status).toBe(200); + const body = await readNdjsonResponse(res); + expect(body.error).toBe('Server error'); + }); + + describe('metadata cache', () => { + beforeEach(() => { + mockParquetMetadataAsync.mockResolvedValue({ num_rows: 5n, row_groups: [] }); + mockParquetRead.mockImplementation((...args: unknown[]) => { + const data = Array.from({ length: 5 }, (_, i) => BigInt(i)); + return resolvedMockParquetRead(args, { id: data }); + }); + mockParquetSchema.mockReturnValue({ + children: [{ element: { name: 'id' } }] + }); + }); + + function providerWithMetadata(meta: { size: number; etag?: string; lastModified?: Date }) { + return makeProvider({ + getMetadata: vi.fn( + async () => + ({ ...meta, contentType: 'application/x-parquet' }) as Awaited< + ReturnType + > + ), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + } + + it('reuses the parsed metadata while the ETag is unchanged', async () => { + const provider = providerWithMetadata({ size: 1024, etag: '"v1"' }); + + await getParquetPreview(provider, 'cached.parquet', 0, 5, mockLog); + await getParquetPreview(provider, 'cached.parquet', 0, 5, mockLog); + + expect(mockParquetMetadataAsync).toHaveBeenCalledTimes(1); + }); + + it('reparses the metadata once the ETag changes', async () => { + const meta = { size: 1024, etag: '"v1"' }; + const provider = providerWithMetadata(meta); + + await getParquetPreview(provider, 'changed.parquet', 0, 5, mockLog); + meta.etag = '"v2"'; + await getParquetPreview(provider, 'changed.parquet', 0, 5, mockLog); + await getParquetPreview(provider, 'changed.parquet', 0, 5, mockLog); + + expect(mockParquetMetadataAsync).toHaveBeenCalledTimes(2); + }); + + it('falls back to size and last-modified time without an ETag', async () => { + const meta = { size: 1024, lastModified: new Date('2026-10-15T10:00:00Z') }; + const provider = providerWithMetadata(meta); + + await getParquetPreview(provider, 'no-etag.parquet', 0, 5, mockLog); + await getParquetPreview(provider, 'no-etag.parquet', 0, 5, mockLog); + expect(mockParquetMetadataAsync).toHaveBeenCalledTimes(1); + + meta.lastModified = new Date('2026-10-15T10:05:00Z'); + await getParquetPreview(provider, 'no-etag.parquet', 0, 5, mockLog); + expect(mockParquetMetadataAsync).toHaveBeenCalledTimes(2); + }); + + it('does not cache metadata of an object without a version', async () => { + const provider = providerWithMetadata({ size: 1024 }); + + await getParquetPreview(provider, 'unversioned.parquet', 0, 5, mockLog); + await getParquetPreview(provider, 'unversioned.parquet', 0, 5, mockLog); + + expect(mockParquetMetadataAsync).toHaveBeenCalledTimes(2); + }); + + it('uses the metadata passed in instead of another HEAD', async () => { + const provider = providerWithMetadata({ size: 1024, etag: '"v1"' }); + + await getParquetPreview(provider, 'passed.parquet', 0, 5, mockLog, { + size: 1024, + etag: '"v1"' + }); + + expect(provider.getMetadata).not.toHaveBeenCalled(); + }); + }); + + it('blocks preview for GZIP parquet without offset index', async () => { + mockParquetMetadataAsync.mockResolvedValue({ + num_rows: 5n, + row_groups: [ + { + num_rows: 5n, + total_byte_size: 1000n, + columns: [ + { + file_offset: 100n, + meta_data: { + type: 'BYTE_ARRAY', + codec: 'GZIP', + path_in_schema: ['name'], + num_values: 5n, + total_compressed_size: 200n, + total_uncompressed_size: 100n, + data_page_offset: 100n, + encodings: ['PLAIN'] + }, + offset_index_offset: null, + offset_index_length: null + } + ] + } + ], + created_by: 'test', + version: 1 + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview(provider, 'gzip-no-offset.parquet', 0, 250, mockLog); + + expect(res.status).toBe(200); + expect(res.headers.get('X-Preview-Format')).toBe('parquet'); + expect(res.headers.get('X-Preview-Renderable')).toBe('true'); + expect(res.headers.get('X-Preview-Data-Blocked')).toBe('true'); + expect(res.headers.get('X-Preview-Total-Size')).toBe('1024'); + const body = await readNdjsonResponse(res); + expect(body.headers).toEqual(['id', 'name', 'active']); + expect(body.metadata).toBeDefined(); + expect(body.metadata?.rowGroups).toBe(1); + expect(body.rows).toEqual([]); + }); + + it('allows preview for GZIP parquet when offset index is present', async () => { + mockParquetMetadataAsync.mockResolvedValue({ + num_rows: 2n, + row_groups: [ + { + num_rows: 2n, + total_byte_size: 500n, + columns: [ + { + file_offset: 100n, + meta_data: { + type: 'BYTE_ARRAY', + codec: 'GZIP', + path_in_schema: ['name'], + num_values: 2n, + total_compressed_size: 100n, + total_uncompressed_size: 50n, + data_page_offset: 100n, + encodings: ['PLAIN'] + }, + offset_index_offset: 500n, + offset_index_length: 50 + } + ] + } + ], + created_by: 'test', + version: 1 + }); + mockParquetSchema.mockReturnValue({ + children: [ + { + element: { + name: 'name', + type: 'BYTE_ARRAY', + converted_type: 'UTF8', + logical_type: { type: 'STRING' } + } + } + ] + }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { name: ['Alice', 'Bob'] }) + ); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview( + provider, + 'gzip-with-offset.parquet', + 0, + 250, + mockLog, + undefined, + true + ); + + expect(res.status).toBe(200); + expect(res.headers.get('X-Preview-Format')).toBe('parquet'); + expect(res.headers.get('X-Preview-Renderable')).toBe('true'); + const body = await readNdjsonResponse(res); + expect(body.headers).toEqual(['name']); + expect(body.rows).toEqual([['Alice'], ['Bob']]); + }); + + it('allows preview for non-GZIP parquet without offset index', async () => { + mockParquetMetadataAsync.mockResolvedValue({ + num_rows: 2n, + row_groups: [ + { + num_rows: 2n, + total_byte_size: 500n, + columns: [ + { + file_offset: 100n, + meta_data: { + type: 'BYTE_ARRAY', + codec: 'SNAPPY', + path_in_schema: ['name'], + num_values: 2n, + total_compressed_size: 100n, + total_uncompressed_size: 50n, + data_page_offset: 100n, + encodings: ['PLAIN'] + }, + offset_index_offset: null, + offset_index_length: null + } + ] + } + ], + created_by: 'test', + version: 1 + }); + mockParquetSchema.mockReturnValue({ + children: [ + { + element: { + name: 'name', + type: 'BYTE_ARRAY', + converted_type: 'UTF8', + logical_type: { type: 'STRING' } + } + } + ] + }); + mockParquetRead.mockImplementation((...args: unknown[]) => + resolvedMockParquetRead(args, { name: ['Alice'] }) + ); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + const res = await getParquetPreview(provider, 'snappy-no-offset.parquet', 0, 250, mockLog); + + expect(res.status).toBe(200); + expect(res.headers.get('X-Preview-Format')).toBe('parquet'); + expect(res.headers.get('X-Preview-Renderable')).toBe('true'); + const body = await readNdjsonResponse(res); + expect(body.headers).toEqual(['name']); + }); +}); diff --git a/src/lib/server/storage/preview/parquet.ts b/src/lib/server/storage/preview/parquet.ts index 993b32dc..b36bc57a 100644 --- a/src/lib/server/storage/preview/parquet.ts +++ b/src/lib/server/storage/preview/parquet.ts @@ -1,9 +1,24 @@ import { gunzipSync } from 'node:zlib'; -import { parquetMetadataAsync, parquetReadObjects, parquetSchema } from 'hyparquet'; +import { + parquetMetadataAsync, + parquetRead, + parquetSchema, + type FileMetaData, + type CompressionCodec, + type SchemaTree +} from 'hyparquet'; import { compressors } from 'hyparquet-compressors'; import type pino from 'pino'; +import { logger } from '$lib/server/logging'; import type { StorageProvider } from '$lib/server/storage/provider.js'; -import { filePreviewRows } from '$lib/server/feature-flags.js'; +import { + parquetDisallowedCompression, + infiniteScrollEnabled, + type ParquetDisallowedCompression +} from '$lib/server/feature-flags'; +import { ExpiringCache, objectVersion, previewCacheKey, type ObjectVersionInfo } from './cache.js'; + +const fallbackLog = logger.child({ module: 'parquet-preview' }); /** * Override the pure-JS GZIP decompressor from hyparquet-compressors with @@ -11,20 +26,387 @@ import { filePreviewRows } from '$lib/server/feature-flags.js'; */ const nodeCompressors = { ...compressors, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature must match hyparquet's expectation of (input, outputLength) + // eslint-disable-next-line @typescript-eslint/no-unused-vars GZIP: (input: Uint8Array, _outputLength: number): Uint8Array => new Uint8Array(gunzipSync(input)) }; -/** Read a ReadableStream into an ArrayBuffer. */ +/** Parsed footers, valid only while the file still has the same `version`. */ +const metadataCache = new ExpiringCache<{ meta: FileMetaData; version: string }>(); + +/** + * Check whether a parquet file's row groups contain any column matching a + * disallowed compression rule. + */ +function findBlockingRule( + meta: FileMetaData, + disallowed: ParquetDisallowedCompression[] +): ParquetDisallowedCompression | undefined { + return disallowed.find((rule) => + meta.row_groups.some((group) => + group.columns.some((col) => { + if (col.meta_data?.codec !== rule.codec) return false; + if (!rule.requireOffsetIndex) return true; + const hasOffsetIndex = + col.offset_index_offset !== undefined && + col.offset_index_offset !== null && + col.offset_index_length !== undefined && + col.offset_index_length > 0; + return !hasOffsetIndex; + }) + ) + ); +} + +function stringifyStructuredParquetValue(value: object): string { + return JSON.stringify(value, (_key, nestedValue: unknown) => { + if (typeof nestedValue === 'bigint') return nestedValue.toString(); + if (nestedValue instanceof Date) return nestedValue.toISOString(); + if (nestedValue instanceof Uint8Array) return Array.from(nestedValue); + return nestedValue; + }); +} + +/** + * Statistics aggregated across all row groups for a single column. + */ +interface ColumnStats { + nullCount: number | null; + distinctCount: number | null; + min: string | null; + max: string | null; +} + +/** + * Per-column detail assembled from all row groups. + */ +interface ColumnDetail { + name: string; + type: string; + codec: string; + compressedSize: number; + uncompressedSize: number; + stats: ColumnStats; +} + +/** Serialize a min/max stat value to a string for client transmission. */ +function statToString(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === 'bigint') return value.toString(); + if (value instanceof Date) return value.toISOString(); + if (value instanceof Uint8Array) + return `<${Array.from(value.slice(0, 8)).join(',')}${value.length > 8 ? '...' : ''}>`; + if (typeof value === 'string') return value.length > 100 ? value.slice(0, 100) + '…' : value; + if (typeof value === 'number') return String(value); + if (typeof value === 'boolean') return String(value); + return String(value); +} + +/** + * Collect per-column details (sizes, codec, stats) from all row groups. + */ +function collectColumnDetails(meta: FileMetaData, schemaTree: SchemaTree): ColumnDetail[] { + return schemaTree.children.map((entry) => { + const colName = entry.element.name; + let compressedSize = 0; + let uncompressedSize = 0; + const codecs = new Set(); + let aggNullCount: number | null = null; + let aggDistinctCount: number | null = null; + let aggMin: string | null = null; + let aggMax: string | null = null; + let hasAnyStats = false; + + for (const group of meta.row_groups) { + for (const col of group.columns) { + const md = col.meta_data; + if (!md) continue; + if ( + md.path_in_schema.length === 0 || + md.path_in_schema[md.path_in_schema.length - 1] !== colName + ) + continue; + + codecs.add(md.codec); + compressedSize += Number(md.total_compressed_size); + uncompressedSize += Number(md.total_uncompressed_size); + + const st = md.statistics; + if (st) { + hasAnyStats = true; + const nullCount = st.null_count !== undefined ? Number(st.null_count) : null; + const distinctCount = st.distinct_count !== undefined ? Number(st.distinct_count) : null; + + if (nullCount !== null) { + aggNullCount = (aggNullCount ?? 0) + nullCount; + } + if (distinctCount !== null) { + aggDistinctCount = (aggDistinctCount ?? 0) + distinctCount; + } + + const minVal = statToString(st.min ?? st.min_value ?? null); + const maxVal = statToString(st.max ?? st.max_value ?? null); + + if (minVal !== null) { + if (aggMin === null || minVal < aggMin) aggMin = minVal; + } + if (maxVal !== null) { + if (aggMax === null || maxVal > aggMax) aggMax = maxVal; + } + } + } + } + + const codec = codecs.size === 1 ? codecs.values().next().value! : 'MIXED'; + + return { + name: colName, + type: describeColumnType(entry.element), + codec, + compressedSize, + uncompressedSize, + stats: { + nullCount: hasAnyStats ? aggNullCount : null, + distinctCount: hasAnyStats ? aggDistinctCount : null, + min: hasAnyStats ? aggMin : null, + max: hasAnyStats ? aggMax : null + } + }; + }); +} + +/** + * Try to decode the embedded Arrow schema from Parquet key_value_metadata. + * Returns a human-readable description (field names & types), or null on failure. + * + * The ARROW:schema value is a base64-encoded Arrow IPC serialised Schema + * (Flatbuffers format inside an IPC message framing). + */ +function extractArrowSchema(meta: FileMetaData): string | null { + if (!meta.key_value_metadata) return null; + const kv = meta.key_value_metadata.find((e) => e.key === 'ARROW:schema'); + if (!kv?.value) return null; + + try { + const bytes = Uint8Array.from(atob(kv.value), (c) => c.charCodeAt(0)); + if (bytes.length < 8) return null; + + const dv = (pos: number) => new DataView(bytes.buffer, bytes.byteOffset + pos); + + // IPC framing: optional 0xFFFFFFFF continuation indicator, then 4-byte metadata length + let pos = 0; + let metaLen: number; + const first = dv(0).getInt32(0, true); + if (first === -1) { + pos = 8; + metaLen = dv(4).getInt32(0, true); + } else { + metaLen = first; + pos = 4; + } + if (pos + metaLen > bytes.length) return null; + + // Flatbuffers helpers operating on the metadata slice + const fb = { + buf: bytes.slice(pos, pos + metaLen), + i32(at: number) { + return new DataView(this.buf.buffer, this.buf.byteOffset + at).getInt32(0, true); + }, + i16(at: number) { + return new DataView(this.buf.buffer, this.buf.byteOffset + at).getInt16(0, true); + }, + i8(at: number) { + return new DataView(this.buf.buffer, this.buf.byteOffset + at).getInt8(0); + } + }; + + // Parse a Flatbuffers table: return field offsets (0 = absent) + function tableFields(tab: number): number[] { + const vtOff = tab - fb.i32(tab); // vtable location + const n = fb.i16(vtOff + 2); // number of fields + const offs: number[] = []; + for (let i = 0; i < n; i++) offs.push(fb.i16(vtOff + 4 + i * 2)); + return offs; + } + + // Read a string at a uoffset_t pointer + function fbStr(at: number): string { + const strOff = fb.i32(at); // uoffset_t relative to `at` + const start = at + strOff; + const len = fb.i32(start); + return new TextDecoder().decode(fb.buf.slice(start + 4, start + 4 + len)); + } + + // Read a uoffset_t and return the absolute offset + function fbOff(at: number): number { + return at + fb.i32(at); + } + + // Read a vector (returns element start offset and count) + function fbVec(at: number): { elemStart: number; count: number } | null { + const vecOff = fb.i32(at); + const start = at + vecOff; + const count = fb.i32(start); + return { elemStart: start + 4, count }; + } + + // Message table starts at 0 within the metadata block + const msgFields = tableFields(0); + if (msgFields.length < 3) return null; + + // Field 1 (header_type, ubyte) — tab=0 so fieldPos = msgFields[1] + if (msgFields[1] === 0) return null; + const headerType = fb.i8(msgFields[1]); + if (headerType !== 1) return null; // 1 = Schema + + // Field 2 (header, table offset) — tab=0 so fieldPos = msgFields[2] + if (msgFields[2] === 0) return null; + const schemaTab = fbOff(msgFields[2]); + + // Schema table: field 1 = fields vector + const schemaFields = tableFields(schemaTab); + if (schemaFields.length < 2 || schemaFields[1] === 0) return null; + // fieldPos for fields vector: schemaTab + schemaFields[1] + const vec = fbVec(schemaTab + schemaFields[1]); + if (!vec || vec.count === 0) return null; + + const fieldEntries: string[] = []; + for (let i = 0; i < vec.count; i++) { + const fieldTabOff = fb.i32(vec.elemStart + i * 4); + const fieldTabAbs = vec.elemStart + i * 4 + fieldTabOff; + const ff = tableFields(fieldTabAbs); + if (ff.length > 0 && ff[0] !== 0) { + const name = fbStr(fieldTabAbs + ff[0]); + fieldEntries.push(name); + } + } + + if (fieldEntries.length === 0) return null; + return fieldEntries.join(', '); + } catch { + return null; + } +} + +/** Extract a human-readable type string from a schema element. */ +function describeColumnType(element: SchemaTree['element']): string { + if (element.logical_type) { + const lt = element.logical_type; + if (lt.type === 'STRING') return 'string'; + if (lt.type === 'INTEGER') return `int(${lt.bitWidth})`; + if (lt.type === 'DECIMAL') return `decimal(${lt.precision},${lt.scale})`; + if (lt.type === 'DATE') return 'date'; + if (lt.type === 'TIME') return 'time'; + if (lt.type === 'TIMESTAMP') return 'timestamp'; + if (lt.type === 'ENUM') return 'enum'; + if (lt.type === 'UUID') return 'uuid'; + if (lt.type === 'JSON') return 'json'; + if (lt.type === 'BSON') return 'bson'; + if (lt.type === 'MAP') return 'map'; + if (lt.type === 'LIST') return 'list'; + if (lt.type === 'FLOAT16') return 'float16'; + if (lt.type === 'VARIANT') return 'variant'; + if (lt.type === 'NULL') return 'null'; + if (lt.type === 'INTERVAL') return 'interval'; + if (lt.type === 'GEOMETRY') return 'geometry'; + if (lt.type === 'GEOGRAPHY') return 'geography'; + } + if (element.converted_type) { + if (element.converted_type === 'UTF8') return 'string'; + if (element.converted_type === 'MAP') return 'map'; + if (element.converted_type === 'LIST') return 'list'; + if (element.converted_type === 'ENUM') return 'enum'; + if (element.converted_type === 'DECIMAL') + return `decimal(${element.precision},${element.scale})`; + if (element.converted_type === 'DATE') return 'date'; + if (element.converted_type === 'TIME_MILLIS') return 'time_ms'; + if (element.converted_type === 'TIME_MICROS') return 'time_us'; + if (element.converted_type === 'TIMESTAMP_MILLIS') return 'timestamp_ms'; + if (element.converted_type === 'TIMESTAMP_MICROS') return 'timestamp_us'; + if (element.converted_type === 'UINT_8') return 'uint8'; + if (element.converted_type === 'UINT_16') return 'uint16'; + if (element.converted_type === 'UINT_32') return 'uint32'; + if (element.converted_type === 'UINT_64') return 'uint64'; + if (element.converted_type === 'INT_8') return 'int8'; + if (element.converted_type === 'INT_16') return 'int16'; + if (element.converted_type === 'INT_32') return 'int32'; + if (element.converted_type === 'INT_64') return 'int64'; + if (element.converted_type === 'JSON') return 'json'; + if (element.converted_type === 'BSON') return 'bson'; + if (element.converted_type === 'INTERVAL') return 'interval'; + } + if (element.type === 'BOOLEAN') return 'boolean'; + if (element.type === 'INT32') return 'int32'; + if (element.type === 'INT64') return 'int64'; + if (element.type === 'INT96') return 'int96'; + if (element.type === 'FLOAT') return 'float'; + if (element.type === 'DOUBLE') return 'double'; + if (element.type === 'BYTE_ARRAY') return 'binary'; + if (element.type === 'FIXED_LEN_BYTE_ARRAY') return 'fixed_binary'; + return 'unknown'; +} + +/** Collect the unique compression codecs used across all row groups. */ +function collectCompressionCodecs(meta: FileMetaData): CompressionCodec[] { + const codecs = new Set(); + for (const group of meta.row_groups) { + for (const col of group.columns) { + if (col.meta_data?.codec) { + codecs.add(col.meta_data.codec); + } + } + } + return Array.from(codecs); +} + +/** Check whether the file has an offset index on at least one column. */ +function hasOffsetIndex(meta: FileMetaData): boolean { + return meta.row_groups.some((group) => + group.columns.some( + (col) => + col.offset_index_offset !== undefined && + col.offset_index_offset !== null && + col.offset_index_length !== undefined && + col.offset_index_length > 0 + ) + ); +} + +/** Check whether the file has a column index on at least one column. */ +function hasColumnIndex(meta: FileMetaData): boolean { + return meta.row_groups.some((group) => + group.columns.some( + (col) => + col.column_index_offset !== undefined && + col.column_index_offset !== null && + col.column_index_length !== undefined && + col.column_index_length > 0 + ) + ); +} + +function toSerializableParquetCell(value: unknown): string | number | boolean | null { + if (value === null || value === undefined) return null; + if (typeof value === 'bigint') return value.toString(); + if (value instanceof Date) return value.toISOString(); + if (value instanceof Uint8Array) return Array.from(value).join(','); + if (Array.isArray(value) || typeof value === 'object') + return stringifyStructuredParquetValue(value as object); + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') + return value; + return String(value); +} + async function streamToArrayBuffer(stream: ReadableStream): Promise { const chunks: Uint8Array[] = []; const reader = stream.getReader(); + while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value as Uint8Array); } - const totalLength = chunks.reduce((acc, c) => acc + c.length, 0); + + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { @@ -34,160 +416,310 @@ async function streamToArrayBuffer(stream: ReadableStream): Promise return result.buffer; } -/** Serialise a parquet cell value to a string suitable for CSV embedding. */ -function formatParquetValue(value: unknown): string { - if (value === null || value === undefined) return ''; - if (typeof value === 'bigint') return value.toString(); - if (value instanceof Date) return value.toISOString(); - if (typeof value === 'object') return JSON.stringify(value); - return String(value); -} - -/** Escape a single CSV field (RFC 4180). */ -function escapeCSVField(field: string): string { - if (field.includes(',') || field.includes('"') || field.includes('\n') || field.includes('\r')) { - return '"' + field.replace(/"/g, '""') + '"'; - } - return field; -} - -export async function parquetPreview( +export async function getParquetPreview( provider: StorageProvider, key: string, - totalSize: number, - userId: string, - log: pino.Logger + offset = 0, + limit = 250, + requestLog: pino.Logger = fallbackLog, + object?: ObjectVersionInfo, + includeData = false, + bucket = '', + connectionId = '' ): Promise { - // ── Step 1: footer (serial, ≤ 2 range requests) ──────────────────────── - // hyparquet fetches the last 512 KB first; if the footer is larger it - // makes a second request. Serial avoids ETIMEDOUT. - let footerQueue: Promise = Promise.resolve(); - const footerBuffer = { - byteLength: totalSize, - slice: (start: number, end?: number): Promise => { - const rangeEnd = end !== undefined ? end - 1 : totalSize - 1; - const req = footerQueue.then(async () => { - const stream = await provider.getObjectRange(key, start, rangeEnd); - return streamToArrayBuffer(stream as ReadableStream); - }); - footerQueue = req.then( - () => {}, - () => {} - ); - return req; - } - }; + const log = requestLog.child({ module: 'parquet-preview' }); + object ??= await provider.getMetadata(key); + const byteLength = object.size; + const version = objectVersion(object); + + if (byteLength === 0) { + const meta = { + rowGroups: 0, + compressionCodecs: [] as string[], + compressionUniform: true, + hasOffsetIndex: false, + hasColumnIndex: false, + createdBy: null as string | null, + version: 0, + arrowSchema: null as string | null + }; + return new Response(JSON.stringify({ t: 'h', h: [], tr: 0, s: [], m: meta }) + '\n', { + headers: { + 'Content-Type': 'application/x-ndjson', + 'X-Preview-Format': 'parquet', + 'X-Preview-Renderable': 'true', + 'X-Preview-Infinite-Scroll': String(infiniteScrollEnabled), + 'X-Preview-Data-Blocked': 'false', + 'X-Preview-Total-Size': '0', + 'X-Preview-Total-Rows': '0', + 'X-Preview-Offset': String(offset), + 'Cache-Control': 'no-store' + } + }); + } + + let parquetMeta: FileMetaData; + + const cacheKey = previewCacheKey(connectionId, bucket, key); + const cached = version === undefined ? undefined : metadataCache.get(cacheKey); + if (cached && cached.version === version) { + parquetMeta = cached.meta; + log.debug({ key }, 'Used in-memory cached Parquet metadata'); + } else { + if (cached) log.debug({ key }, 'Parquet file changed, reparsing metadata'); + let footerQueue: Promise = Promise.resolve(); + const footerBuffer = { + byteLength, + slice: (start: number, end?: number): Promise => { + const rangeEnd = end !== undefined ? end - 1 : byteLength - 1; + const request = footerQueue.then(async () => { + const stream = await provider.getObjectRange(key, start, rangeEnd); + return streamToArrayBuffer(stream as ReadableStream); + }); + footerQueue = request.then( + () => {}, + () => {} + ); + return request; + } + }; + + parquetMeta = await parquetMetadataAsync(footerBuffer); + + if (version !== undefined) metadataCache.set(cacheKey, { meta: parquetMeta, version }); + } - const parquetMeta = await parquetMetadataAsync(footerBuffer); const totalRows = Number(parquetMeta.num_rows); - const previewRows = Math.min(totalRows, filePreviewRows); - const truncated = previewRows < totalRows; - const schema = parquetSchema(parquetMeta); - const columnNames = schema.children.map((e) => e.element.name); - - // ── Step 2: pre-fetch OffsetIndex in ONE merged range request ─────────── - // OffsetIndex entries for all columns are stored contiguously near the - // end of the file (written just before the file metadata). Fetching them - // together eliminates one S3 round-trip per column (~30 saved requests). - let oiMin = Infinity; - let oiMax = 0; - let rowsScanned = 0; - for (const rg of parquetMeta.row_groups) { - if (rowsScanned >= previewRows) break; - rowsScanned += Number(rg.num_rows); - for (const col of rg.columns) { - if (col.offset_index_offset && col.offset_index_length) { - const s = Number(col.offset_index_offset); - const e = s + col.offset_index_length; - if (s < oiMin) oiMin = s; - if (e > oiMax) oiMax = e; + const schemaTree = parquetSchema(parquetMeta); + const headers = schemaTree.children.map((entry) => entry.element.name); + + const columnDetails = collectColumnDetails(parquetMeta, schemaTree); + const arrowSchema = extractArrowSchema(parquetMeta); + const metadata = { + rowGroups: parquetMeta.row_groups.length, + compressionCodecs: collectCompressionCodecs(parquetMeta), + compressionUniform: new Set(columnDetails.map((c) => c.codec)).size <= 1, + hasOffsetIndex: hasOffsetIndex(parquetMeta), + hasColumnIndex: hasColumnIndex(parquetMeta), + createdBy: parquetMeta.created_by ?? null, + version: parquetMeta.version, + arrowSchema + }; + + // ── Check if compression rules disallow data preview ───────── + const blockingRule = findBlockingRule(parquetMeta, parquetDisallowedCompression); + + // Always return header message with schema + metadata (even when blocked). + // When blocked, set data-blocked header and never stream column data. + if (blockingRule || !includeData) { + log.warn( + { key, codec: blockingRule?.codec, require_offset_index: blockingRule?.requireOffsetIndex }, + blockingRule ? 'Blocked parquet preview by compression rule' : 'Metadata-only parquet request' + ); + const extraHeaders: Record = {}; + if (blockingRule) extraHeaders['X-Preview-Data-Blocked'] = 'true'; + return new Response( + JSON.stringify({ t: 'h', h: headers, tr: totalRows, s: columnDetails, m: metadata }) + '\n', + { + headers: { + 'Content-Type': 'application/x-ndjson', + 'X-Preview-Format': 'parquet', + 'X-Preview-Renderable': 'true', + 'X-Preview-Infinite-Scroll': String(infiniteScrollEnabled), + ...extraHeaders, + 'X-Preview-Total-Size': String(byteLength), + 'X-Preview-Total-Rows': String(totalRows), + 'X-Preview-Offset': String(offset), + 'Cache-Control': 'no-store' + } + } + ); + } + + const actualLimit = Math.min(limit, Math.max(0, totalRows - offset)); + + if (actualLimit <= 0) { + return new Response( + JSON.stringify({ t: 'h', h: headers, tr: totalRows, s: columnDetails, m: metadata }) + '\n', + { + headers: { + 'Content-Type': 'application/x-ndjson', + 'X-Preview-Format': 'parquet', + 'X-Preview-Renderable': 'true', + 'X-Preview-Infinite-Scroll': String(infiniteScrollEnabled), + 'X-Preview-Truncated': 'false', + 'X-Preview-Total-Size': String(byteLength), + 'X-Preview-Total-Rows': String(totalRows), + 'X-Preview-Offset': String(offset), + 'Cache-Control': 'no-store' + } + } + ); + } + + const requestedEnd = offset + actualLimit; + + // Pre-fetch up to MAX_PREVIEW_FETCH_BYTES from the start of the file. + // For the common case (data of the first row group fits within the limit), + // this means hyparquet reads entirely from memory — only 2 HTTP requests + // total (footer + this). When column data extends beyond the limit, + // individual slice() calls fall through to per-request HTTP fetches + // (bounded by useOffsetIndex to only pages needed for the requested rows). + const MAX_PREVIEW_FETCH_BYTES = 5 * 1024 * 1024; // magic number: amount of data in column to load + const previewLimit = Math.min(MAX_PREVIEW_FETCH_BYTES, byteLength); + + /** In-memory buffer plus any extra ranges fetched for out-of-preview data. */ + const bufferCache: Array<{ start: number; buffer: ArrayBuffer }> = []; + + if (previewLimit > 0) { + const stream = await provider.getObjectRange(key, 0, previewLimit - 1); + bufferCache.push({ start: 0, buffer: await streamToArrayBuffer(stream as ReadableStream) }); + } + + // Also fetch exact byte spans for any row groups whose data starts beyond + // the preview limit, so we don't degrade to per-slice HTTP for those. + const extraFetches: Array<{ start: number; end: number }> = []; + let groupRowStart = 0; + for (const rowGroup of parquetMeta.row_groups) { + const groupRows = Number(rowGroup.num_rows); + const groupRowEnd = groupRowStart + groupRows; + + if (groupRowEnd > offset && groupRowStart < requestedEnd) { + let minByte = Infinity; + let maxByte = -Infinity; + + for (const column of rowGroup.columns) { + const meta = column.meta_data; + if (!meta) continue; + + const colStart = Number(meta.dictionary_page_offset ?? meta.data_page_offset); + const colEnd = colStart + Number(meta.total_compressed_size); + if (colStart < minByte) minByte = colStart; + if (colEnd > maxByte) maxByte = colEnd; + + if (column.offset_index_offset != null && column.offset_index_length != null) { + const offEnd = Number(column.offset_index_offset) + Number(column.offset_index_length); + if (offEnd > maxByte) maxByte = offEnd; + } + if (column.column_index_offset != null && column.column_index_length != null) { + const ciEnd = Number(column.column_index_offset) + Number(column.column_index_length); + if (ciEnd > maxByte) maxByte = ciEnd; + } + } + + if (isFinite(minByte) && maxByte > minByte && minByte >= previewLimit) { + extraFetches.push({ start: minByte, end: maxByte }); } } + + groupRowStart = groupRowEnd; } - let oiCache: { start: number; buf: ArrayBuffer } | null = null; - if (isFinite(oiMin)) { - const oiStream = await provider.getObjectRange(key, oiMin, oiMax - 1); - oiCache = { start: oiMin, buf: await streamToArrayBuffer(oiStream as ReadableStream) }; - log.debug({ user_id: userId, key, oi_bytes: oiMax - oiMin }, 'parquet OffsetIndex pre-fetched'); + + if (extraFetches.length > 0) { + const results = await Promise.all( + extraFetches.map((r) => + provider + .getObjectRange(key, r.start, r.end - 1) + .then((s) => streamToArrayBuffer(s as ReadableStream)) + .then((buf) => ({ start: r.start, buffer: buf })) + ) + ); + bufferCache.push(...results); } - // ── Step 3: concurrency-limited buffer for data page reads ────────────── - // prefetchAsyncBuffer inside hyparquet calls file.slice() for every fetch - // in one synchronous pass. Limit to 4 concurrent S3 connections to stay - // well below the threshold that triggers ETIMEDOUT on the S3 endpoint, - // while being 4× faster than serial (30 pages / 4 = 8 batches × ~1.5s). - const CONCURRENCY = 4; - let running = 0; - const waiters: Array<() => void> = []; - const acquire = (): Promise => - running < CONCURRENCY - ? (running++, Promise.resolve()) - : new Promise((resolve) => waiters.push(resolve)); - const release = () => { - const next = waiters.shift(); - if (next) next(); - else running--; + const asyncFile = { + byteLength, + slice: async (_start: number, _end?: number): Promise => { + const end = _end ?? byteLength; + for (const { start, buffer } of bufferCache) { + const bufEnd = start + buffer.byteLength; + if (_start >= start && end <= bufEnd) { + return buffer.slice(_start - start, end - start); + } + } + const stream = await provider.getObjectRange(key, _start, end - 1); + return streamToArrayBuffer(stream as ReadableStream); + } }; - const asyncBuffer = { - byteLength: totalSize, - slice: (start: number, end?: number): Promise => { - const rangeEnd = end ?? totalSize; - // Serve OffsetIndex reads from the pre-fetched in-memory cache. - if (oiCache && start >= oiCache.start && rangeEnd <= oiCache.start + oiCache.buf.byteLength) { - return Promise.resolve(oiCache.buf.slice(start - oiCache.start, rangeEnd - oiCache.start)); - } - // All other reads (data pages): rate-limited S3 range request. - return acquire().then(async () => { + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + // Send headers immediately with schema and metadata + controller.enqueue( + encoder.encode( + JSON.stringify({ t: 'h', h: headers, tr: totalRows, s: columnDetails, m: metadata }) + + '\n' + ) + ); + + // Use parquetRead with onChunk to stream columns as they load. + // Trim columnData to the requested [offset, requestedEnd) range because + // hyparquet's onChunk fires with page-granularity data that can include + // rows outside the requested range (intra-page trimming only happens in + // asyncGroupToRows called by onComplete, not in onChunk). + const readPromise = parquetRead({ + file: asyncFile, + metadata: parquetMeta, + rowStart: offset, + rowEnd: requestedEnd, + useOffsetIndex: true, + compressors: nodeCompressors, + rowFormat: 'object', + onChunk: ({ columnName, columnData, rowStart: chunkStart, rowEnd: chunkEnd }) => { + try { + const trimStart = Math.max(0, offset - chunkStart); + const trimEnd = Math.max(0, chunkEnd - requestedEnd); + const sliced = + trimStart > 0 || trimEnd > 0 + ? columnData.slice(trimStart, columnData.length - trimEnd) + : columnData; + const values = Array.from(sliced, (v: unknown) => toSerializableParquetCell(v)); + controller.enqueue( + encoder.encode(JSON.stringify({ t: 'c', n: columnName, v: values }) + '\n') + ); + } catch (err) { + log.error({ err, key, column: columnName }, 'Error serializing parquet column chunk'); + } + }, + onComplete: () => { + try { + controller.enqueue(encoder.encode(JSON.stringify({ t: 'd' }) + '\n')); + controller.close(); + } catch { + // stream already closed (e.g. client disconnected) + } + } + }); + + readPromise.catch((err) => { + log.error({ err, key }, 'Failed to parse Parquet chunk'); try { - const stream = await provider.getObjectRange(key, start, rangeEnd - 1); - return await streamToArrayBuffer(stream as ReadableStream); - } finally { - release(); + controller.enqueue(encoder.encode(JSON.stringify({ t: 'e' }) + '\n')); + controller.close(); + } catch { + // stream already closed } }); + }, + cancel() { + // Client disconnected — no cleanup needed } - }; - - // useOffsetIndex: true — fetch only the specific pages covering the first - // previewRows rows (requires OffsetIndex in file; falls back to full column - // chunks if absent, which is unavoidable without page-level metadata). - const rows = await parquetReadObjects({ - file: asyncBuffer, - metadata: parquetMeta, - rowEnd: previewRows, - useOffsetIndex: true, - compressors: nodeCompressors }); - const csvLines = [ - columnNames.map(escapeCSVField).join(','), - ...rows.map((row) => - columnNames.map((col) => escapeCSVField(formatParquetValue(row[col]))).join(',') - ) - ]; - const csvText = csvLines.join('\n'); - - log.info( - { - user_id: userId, - key, - total_rows: totalRows, - preview_rows: previewRows, - truncated - }, - 'parquet preview ready' - ); + const truncated = totalRows > offset + actualLimit; - return new Response(csvText, { + return new Response(stream, { headers: { - 'Content-Type': 'text/csv', + 'Content-Type': 'application/x-ndjson', 'X-Preview-Format': 'parquet', 'X-Preview-Renderable': 'true', + 'X-Preview-Infinite-Scroll': String(infiniteScrollEnabled), 'X-Preview-Truncated': String(truncated), - 'X-Preview-Total-Size': String(totalSize), + 'X-Preview-Total-Size': String(byteLength), 'X-Preview-Total-Rows': String(totalRows), - 'X-Preview-Preview-Rows': String(previewRows), + 'X-Preview-Offset': String(offset), 'Cache-Control': 'no-store' } }); diff --git a/src/lib/server/storage/preview/stream.test.ts b/src/lib/server/storage/preview/stream.test.ts index 382f19c7..59008094 100644 --- a/src/lib/server/storage/preview/stream.test.ts +++ b/src/lib/server/storage/preview/stream.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; -import type pino from 'pino'; import { streamPreview } from './stream.js'; import type { StorageProvider } from '$lib/server/storage/provider.js'; +import { createMockLogger } from '$lib/test-utils/mock-logger.js'; -const mockLog = { info: vi.fn(), debug: vi.fn(), warn: vi.fn() } as unknown as pino.Logger; +const mockLog = createMockLogger(); function makeProvider(overrides: Partial = {}): StorageProvider { return { + listContainers: vi.fn(), listObjects: vi.fn(), getObject: vi .fn() @@ -15,8 +16,16 @@ function makeProvider(overrides: Partial = {}): StorageProvider getMetadata: vi.fn(), exists: vi.fn(), putObject: vi.fn(), + putObjectIfMatch: vi.fn(), deleteObjects: vi.fn(), - listAllKeys: vi.fn(), + prefixExists: vi.fn(), + listAllKeysProgressively: vi.fn(), + search: vi.fn(), + getBucketVersioning: vi.fn(), + getBucketLifecycleRules: vi.fn(), + getBucketTags: vi.fn(), + getBucketAcl: vi.fn(), + copyObject: vi.fn(), ...overrides }; } @@ -31,14 +40,42 @@ describe('streamPreview', () => { expect(res.headers.get('X-Preview-Bytes')).toBe('1000'); }); - it('uses getObjectRange when totalSize > text limit', async () => { + it('loads an editable text file completely with the ETag of the GET', async () => { + const provider = makeProvider({ + getObject: vi.fn().mockResolvedValue({ stream: new ReadableStream(), etag: '"e1"' }) + }); + const totalSize = 300 * 1024; // above the 256 KiB text limit, below the 5 MiB edit limit + const res = await streamPreview(provider, 'mid.txt', 'text/plain', totalSize, 'user1', mockLog); + expect(provider.getObject).toHaveBeenCalledWith('mid.txt'); + expect(provider.getObjectRange).not.toHaveBeenCalled(); + expect(res.headers.get('X-Preview-Truncated')).toBe('false'); + expect(res.headers.get('X-Preview-Bytes')).toBe(String(totalSize)); + expect(res.headers.get('X-Preview-ETag')).toBe('"e1"'); + }); + + it.each([ + ['application/octet-stream', 'data.bin'], + ['text/tab-separated-values', 'table.tsv'], + ['text/plain', 'table.tsv'], + ['text/csv', 'table.csv'] + ])('keeps the text preview limit for non-editable %s (%s)', async (contentType, key) => { const provider = makeProvider(); const totalSize = 300 * 1024; + const res = await streamPreview(provider, key, contentType, totalSize, 'user1', mockLog); + expect(provider.getObject).not.toHaveBeenCalled(); + expect(provider.getObjectRange).toHaveBeenCalledWith(key, 0, 256 * 1024 - 1); + expect(res.headers.get('X-Preview-Truncated')).toBe('true'); + }); + + it('uses getObjectRange without an ETag when totalSize > edit limit', async () => { + const provider = makeProvider(); + const totalSize = 6 * 1024 * 1024; const res = await streamPreview(provider, 'big.txt', 'text/plain', totalSize, 'user1', mockLog); expect(provider.getObjectRange).toHaveBeenCalledWith('big.txt', 0, 256 * 1024 - 1); expect(res.headers.get('X-Preview-Truncated')).toBe('true'); expect(res.headers.get('X-Preview-Bytes')).toBe(String(256 * 1024)); expect(res.headers.get('X-Preview-Total-Size')).toBe(String(totalSize)); + expect(res.headers.get('X-Preview-ETag')).toBeNull(); }); it('uses 5MB limit for images', async () => { diff --git a/src/lib/server/storage/preview/stream.ts b/src/lib/server/storage/preview/stream.ts index e7e6df8b..c00dd511 100644 --- a/src/lib/server/storage/preview/stream.ts +++ b/src/lib/server/storage/preview/stream.ts @@ -5,9 +5,18 @@ import { imagePreviewBytes, pdfPreviewBytes, filePreviewRows, - filePreviewColumns + filePreviewColumns, + maxEditableFileSize } from '$lib/server/feature-flags.js'; +import { isEditableTextObject } from '$lib/storage/text-content-type.js'; +/** + * Streams the first bytes of an object for preview. Editable text objects + * (text content types except CSV/TSV) up to the editable size limit are sent + * complete, so the editor never works on a truncated file. Whenever the whole + * object is sent, the ETag of that GET is included (`X-Preview-ETag`) so saves + * can be made conditional on it. Everything else keeps the size-based limits. + */ export async function streamPreview( provider: StorageProvider, key: string, @@ -21,6 +30,10 @@ export async function streamPreview( limitBytes = imagePreviewBytes; } else if (contentType === 'application/pdf') { limitBytes = pdfPreviewBytes; + } else if (isEditableTextObject(key, contentType) && totalSize <= maxEditableFileSize) { + // Loaded completely so it can be edited and saved without splicing the + // unseen tail back in. + limitBytes = totalSize; } else { limitBytes = textPreviewBytes; } @@ -39,10 +52,14 @@ export async function streamPreview( 'fetching object preview' ); - const stream = - previewBytes === totalSize - ? (await provider.getObject(key)).stream - : await provider.getObjectRange(key, 0, previewBytes - 1); + let stream: ReadableStream; + let etag: string | undefined; + if (previewBytes === totalSize) { + // Use the ETag of the GET itself so it always matches the bytes sent. + ({ stream, etag } = await provider.getObject(key)); + } else { + stream = await provider.getObjectRange(key, 0, previewBytes - 1); + } return new Response(stream, { headers: { @@ -53,7 +70,8 @@ export async function streamPreview( 'X-Preview-Bytes': String(previewBytes), 'X-Preview-Preview-Rows': String(filePreviewRows), 'X-Preview-Preview-Columns': String(filePreviewColumns), - 'Cache-Control': 'no-store' + 'Cache-Control': 'no-store', + ...(etag ? { 'X-Preview-ETag': etag } : {}) } }); } diff --git a/src/lib/server/storage/provider.ts b/src/lib/server/storage/provider.ts index 6156762d..89ee8a1b 100644 --- a/src/lib/server/storage/provider.ts +++ b/src/lib/server/storage/provider.ts @@ -1,7 +1,48 @@ -import type { StoragePage, StorageMetadata, DeleteObjectsResult } from '$lib/storage/types.js'; +import type { + StoragePage, + StorageMetadata, + DeleteObjectsResult, + SearchResultItem +} from '$lib/storage/types.js'; +import type { LifecycleRule, BucketAcl, BucketVersioning } from '$lib/storage/details-types.js'; export type { DeleteObjectsResult }; +/** Options accepted by {@link StorageProvider.search}. */ +export interface SearchOptions { + /** Limit the S3 listing to this key prefix. */ + prefix?: string; + /** Maximum key depth relative to `prefix`. */ + maxDepth?: number; + /** Abort the in-flight search; throws `AbortError` when signalled. */ + signal?: AbortSignal; + /** Decide whether a scanned key matches the search. */ + matches?: (item: SearchResultItem) => boolean; + /** Called immediately after each accepted result. */ + onMatch?: (item: SearchResultItem) => void; +} + +/** Options for progressive key listing. */ +export interface ProgressiveListOptions { + /** Abort the currently pending provider page request. */ + signal?: AbortSignal; +} + +/** Options accepted by {@link StorageProvider.copyObject}. */ +export interface CopyObjectOptions { + /** Bucket of the same connection to copy from. Defaults to the provider's bucket. */ + sourceBucket?: string; + /** Size of the source when the caller already knows it; lets the provider skip reading it first. */ + size?: number; + onProgress?: (loaded: number, total: number) => void; + signal?: AbortSignal; +} + +/** Result of a bucket-scoped search. */ +export interface SearchResult { + results: SearchResultItem[]; +} + /** Metadata and body stream returned when fetching a storage object. */ export interface ObjectDownload { stream: ReadableStream; @@ -12,6 +53,8 @@ export interface ObjectDownload { /** Backend-agnostic interface for a bucket-scoped storage provider. */ export interface StorageProvider { + /** List all buckets accessible with the current connection credentials. */ + listContainers(): Promise; /** * List objects using cursor-based pagination. `continuationToken` is the * provider-specific opaque token returned from a previous call. When @@ -37,10 +80,64 @@ export interface StorageProvider { contentType: string, contentLength?: number ): Promise; - deleteObjects(keys: string[]): Promise; /** - * List all object keys under a prefix, recursively (no delimiter). - * Used to expand directory prefixes before deletion. + * Replace an existing object in a single request, but only if its current + * ETag still equals `etag` (S3 conditional write, `If-Match`). Throws an + * HTTP 409 error when the object changed or vanished in the meantime. + * Returns the ETag of the newly written object. + */ + putObjectIfMatch( + key: string, + body: Buffer, + contentType: string, + etag: string + ): Promise; + /** + * Delete objects. With `expandDirectories`, a key ending in `/` also deletes + * every object under it; without, only the named keys are deleted. + */ + deleteObjects(keys: string[], expandDirectories: boolean): Promise; + /** + * Whether any object exists under `prefix`, including a folder marker equal + * to `prefix` itself. Lists without a delimiter, so the marker is not hidden. + */ + prefixExists(prefix: string, signal?: AbortSignal): Promise; + /** + * List all objects under a prefix, recursively (no delimiter), handing them to + * `onBatch` one listing page at a time; empty pages are skipped. Returning `false` + * stops the listing. + */ + listAllKeysProgressively( + prefix: string, + onBatch: (keys: Array<{ key: string; size: number; lastModified?: Date }>) => void | boolean, + options?: ProgressiveListOptions + ): Promise; + /** + * Case-insensitive substring search across all keys in this bucket. Both + * files (plain keys) and directories (keys ending in `/`) match. + */ + search(query: string, options?: SearchOptions): Promise; + /* + * Bucket info getters resolve to `null` when the setting could not be read + * (access denied, unsupported by the backend, ...), as opposed to an empty + * value when it is simply not configured. + */ + getBucketVersioning(): Promise; + getBucketLifecycleRules(): Promise; + getBucketTags(): Promise | null>; + getBucketAcl(): Promise; + /** + * Copy an object from `sourceKey` to `destKey` in this provider's bucket. + * The source is read from `options.sourceBucket` (default: this bucket) of + * the same connection. The copy runs entirely in the storage backend: S3 + * CopyObject for objects up to 5 GB, multipart UploadPartCopy above. Throws + * if the source does not exist or access is denied. Existing destination + * objects are silently overwritten. + * + * `options.onProgress` is called with `(loaded, total)` bytes after each part + * of a multipart copy (> 5 GB); a single CopyObject reports nothing. + * `options.signal` aborts the running requests; an aborted multipart copy is + * still cleaned up. */ - listAllKeys(prefix: string): Promise; + copyObject(sourceKey: string, destKey: string, options?: CopyObjectOptions): Promise; } diff --git a/src/lib/server/storage/recent-searches-db.spec.ts b/src/lib/server/storage/recent-searches-db.spec.ts new file mode 100644 index 00000000..4f868958 --- /dev/null +++ b/src/lib/server/storage/recent-searches-db.spec.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { sql, type SQL } from 'drizzle-orm'; +import { CasingCache } from 'drizzle-orm/casing'; +import { userRecentSearches } from '$lib/server/schema.js'; +import { + listRecentSearches, + recordRecentSearch, + clearRecentSearches, + recentSearchKey, + RECENT_SEARCHES_CAP +} from './recent-searches-db.js'; + +vi.mock('$lib/server/logging', () => ({ + logger: { child: () => ({ trace: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }) } +})); + +const { mockDb } = vi.hoisted(() => ({ + mockDb: { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + execute: vi.fn() + } +})); + +vi.mock('$lib/server/db.js', () => ({ db: mockDb })); + +type ChainFn = (...args: unknown[]) => MockChain; + +/** Mocked Drizzle query builder: every terminal call returns the same chain, + * which is thenable (resolving to `result`) and acts as a SQLWrapper when + * used as a subquery — its `.limit()` value is rendered into `getSQL()`. */ +interface MockChain { + from: Mock; + where: Mock; + orderBy: Mock; + limit: Mock; + values: Mock; + onConflictDoUpdate: Mock; + returning: Mock; + then: (resolve: (value: unknown) => unknown) => Promise; + getSQL: () => SQL; +} + +function buildChain(result: unknown, subquerySQL: string): MockChain { + let limitValue: number | undefined; + const chain: MockChain = { + from: vi.fn(() => chain), + where: vi.fn(() => chain), + orderBy: vi.fn(() => chain), + limit: vi.fn((...args: unknown[]) => { + limitValue = args[0] as number; + return chain; + }), + values: vi.fn(() => chain), + onConflictDoUpdate: vi.fn(() => chain), + returning: vi.fn(() => chain), + then: (resolve: (value: unknown) => unknown) => Promise.resolve(result).then(resolve), + getSQL: () => + sql`${sql.raw(`${subquerySQL}${limitValue !== undefined ? ` limit ${limitValue}` : ''}`)}` + }; + return chain; +} + +/** Serialise a Drizzle SQL wrapper the way the Postgres dialect would. */ +function toQuery(condition: unknown) { + const config = { + escapeName: (n: string) => `"${n}"`, + escapeParam: (index: number) => `$${index + 1}`, + casing: new CasingCache('camelCase'), + paramStartIndex: { value: 0 }, + inlineParams: false + }; + return ( + condition as { toQuery: (config: unknown) => { sql: string; params: unknown[] } } + ).toQuery(config); +} + +const USER_ID = 'user-1'; +const CONNECTION_ID = '00000000-0000-0000-0000-000000000001'; + +describe('recent-searches-db', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.transaction.mockImplementation((callback) => callback(mockDb)); + mockDb.execute.mockResolvedValue(undefined); + }); + + describe('listRecentSearches', () => { + it('selects buckets+query+options for the user+connection, newest first', async () => { + const rows = [ + { buckets: ['b1', 'b2'], query: 'q2' }, + { buckets: ['b1'], query: 'q1' } + ]; + mockDb.select.mockReturnValue(buildChain(rows, 'select id from user_recent_searches')); + + const result = await listRecentSearches(USER_ID, CONNECTION_ID); + + expect(mockDb.select).toHaveBeenCalledWith({ + buckets: userRecentSearches.buckets, + query: userRecentSearches.query, + useRegex: userRecentSearches.useRegex, + excludePatterns: userRecentSearches.excludePatterns, + searchPath: userRecentSearches.searchPath, + maxDepth: userRecentSearches.maxDepth + }); + + const selectChain = mockDb.select.mock.results[0].value; + expect(selectChain.from).toHaveBeenCalledWith(userRecentSearches); + + const whereSql = toQuery(selectChain.where.mock.calls[0][0]); + expect(whereSql.sql).toContain('"user_recent_searches"."user_id" = $1'); + expect(whereSql.sql).toContain('"user_recent_searches"."connection_id" = $2'); + expect(whereSql.params).toEqual([USER_ID, CONNECTION_ID]); + + expect(selectChain.orderBy).toHaveBeenCalledTimes(1); + expect(toQuery(selectChain.orderBy.mock.calls[0][0]).sql).toBe( + '"user_recent_searches"."updated_at" desc' + ); + + expect(result).toEqual(rows); + }); + }); + + describe('recordRecentSearch', () => { + it('upserts on the unique key, bumping updated_at, then prunes to the cap', async () => { + const insertChain = buildChain(undefined, ''); + const subqueryChain = buildChain(undefined, 'select id from user_recent_searches'); + const deleteChain = buildChain(undefined, ''); + + mockDb.insert.mockReturnValue(insertChain); + mockDb.select.mockReturnValue(subqueryChain); + mockDb.delete.mockReturnValue(deleteChain); + + const options = { + useRegex: false, + excludePatterns: ['_temp'], + searchPath: 'events/', + maxDepth: 3 + }; + await recordRecentSearch( + USER_ID, + CONNECTION_ID, + ['bucket-1', 'beta', 'bucket-1'], + 'query-1', + options + ); + + expect(mockDb.insert).toHaveBeenCalledWith(userRecentSearches); + + const insertValues = insertChain.values.mock.calls[0][0] as { + userId: string; + connectionId: string; + buckets: string[]; + query: string; + useRegex: boolean; + excludePatterns: string[]; + searchPath: string; + maxDepth: number; + updatedAt: SQL; + }; + expect(insertValues).toMatchObject({ + userId: USER_ID, + connectionId: CONNECTION_ID, + buckets: ['beta', 'bucket-1'], + query: 'query-1', + useRegex: false, + excludePatterns: ['_temp'], + searchPath: 'events/', + maxDepth: 3, + searchKey: recentSearchKey(['beta', 'bucket-1'], 'query-1', { + useRegex: false, + excludePatterns: ['_temp'], + searchPath: 'events/', + maxDepth: 3 + }) + }); + expect(toQuery(insertValues.updatedAt).sql).toBe('clock_timestamp()'); + + const conflict = insertChain.onConflictDoUpdate.mock.calls[0][0] as { + target: unknown[]; + set: { updatedAt: SQL }; + }; + expect(conflict.target).toEqual([ + userRecentSearches.userId, + userRecentSearches.connectionId, + userRecentSearches.searchKey + ]); + + expect(toQuery(conflict.set.updatedAt).sql).toBe('clock_timestamp()'); + + // Prune: subquery limited to the cap, delete excludes those ids. + expect(subqueryChain.limit).toHaveBeenCalledWith(RECENT_SEARCHES_CAP); + expect(subqueryChain.orderBy).toHaveBeenCalledTimes(1); + expect(mockDb.delete).toHaveBeenCalledWith(userRecentSearches); + + const deleteWhereSql = toQuery(deleteChain.where.mock.calls[0][0]); + expect(deleteWhereSql.sql).toContain('"user_recent_searches"."user_id" = $1'); + expect(deleteWhereSql.sql).toContain('"user_recent_searches"."connection_id" = $2'); + expect(deleteWhereSql.sql).toContain('"user_recent_searches"."id" not in'); + expect(deleteWhereSql.sql).toContain('limit 20'); + expect(deleteWhereSql.params).toEqual([USER_ID, CONNECTION_ID]); + }); + + it('serialises connection history writes with a database transaction lock', async () => { + const insertChain = buildChain(undefined, ''); + const subqueryChain = buildChain(undefined, 'select id from user_recent_searches'); + const deleteChain = buildChain(undefined, ''); + mockDb.insert.mockReturnValue(insertChain); + mockDb.select.mockReturnValue(subqueryChain); + mockDb.delete.mockReturnValue(deleteChain); + + const options = { useRegex: true, excludePatterns: [], searchPath: '', maxDepth: null }; + await recordRecentSearch(USER_ID, CONNECTION_ID, ['bucket-1'], 'query-1', options); + + await recordRecentSearch(USER_ID, CONNECTION_ID, ['bucket-1'], 'query-2', options); + expect(mockDb.transaction).toHaveBeenCalledTimes(2); + expect(toQuery(mockDb.execute.mock.calls[0][0]).sql).toContain('pg_advisory_xact_lock'); + }); + }); + + describe('clearRecentSearches', () => { + it('deletes all rows for the user+connection', async () => { + const deleteChain = buildChain(undefined, ''); + mockDb.delete.mockReturnValue(deleteChain); + + await clearRecentSearches(USER_ID, CONNECTION_ID); + + expect(mockDb.delete).toHaveBeenCalledWith(userRecentSearches); + expect(mockDb.select).not.toHaveBeenCalled(); + + const whereSql = toQuery(deleteChain.where.mock.calls[0][0]); + expect(whereSql.sql).toContain('"user_recent_searches"."user_id" = $1'); + expect(whereSql.sql).toContain('"user_recent_searches"."connection_id" = $2'); + expect(whereSql.params).toEqual([USER_ID, CONNECTION_ID]); + }); + }); +}); + +describe('recentSearchKey', () => { + const options = { useRegex: false, excludePatterns: ['_temp'], searchPath: '', maxDepth: null }; + + it('is a fixed-length hash regardless of input size', () => { + const key = recentSearchKey(['b'], 'q'.repeat(1024), { + ...options, + excludePatterns: Array.from({ length: 50 }, (_, i) => `${i}`.repeat(1024)) + }); + expect(key).toMatch(/^[0-9a-f]{64}$/); + }); + + it('distinguishes searches that differ in any parameter', () => { + const base = recentSearchKey(['b'], 'q', options); + expect(recentSearchKey(['b'], 'q', options)).toBe(base); + expect(recentSearchKey(['b'], 'q', { ...options, useRegex: true })).not.toBe(base); + expect(recentSearchKey(['b'], 'q', { ...options, maxDepth: 1 })).not.toBe(base); + expect(recentSearchKey(['b', 'c'], 'q', options)).not.toBe(base); + expect(recentSearchKey(['b'], 'q', { ...options, excludePatterns: ['_te', 'mp'] })).not.toBe( + base + ); + }); +}); diff --git a/src/lib/server/storage/recent-searches-db.ts b/src/lib/server/storage/recent-searches-db.ts new file mode 100644 index 00000000..28d78ac0 --- /dev/null +++ b/src/lib/server/storage/recent-searches-db.ts @@ -0,0 +1,164 @@ +import { createHash } from 'node:crypto'; +import { and, eq, desc, notInArray, sql } from 'drizzle-orm'; +import { db } from '$lib/server/db.js'; +import { userRecentSearches } from '$lib/server/schema.js'; +import { logger } from '$lib/server/logging'; + +const log = logger.child({ module: 'recent-searches-db' }); + +/** Maximum number of recent searches kept per user and connection. */ +export const RECENT_SEARCHES_CAP = 20; + +/** The advanced options recorded alongside a recent search. */ +export interface RecentSearchOptions { + useRegex: boolean; + excludePatterns: string[]; + searchPath: string; + maxDepth: number | null; +} + +/** A single stored recent search: the query and advanced options plus the + * ordered list of buckets it ran against. Only `maxDepth` can be NULL + * (meaning "no limit"). */ +export interface RecentSearch { + buckets: string[]; + query: string; + useRegex: boolean; + excludePatterns: string[]; + searchPath: string; + maxDepth: number | null; +} + +/** + * List the most recent searches for a user and connection, + * newest first. + */ +export async function listRecentSearches( + userId: string, + connectionId: string +): Promise { + const rows = await db + .select({ + buckets: userRecentSearches.buckets, + query: userRecentSearches.query, + useRegex: userRecentSearches.useRegex, + excludePatterns: userRecentSearches.excludePatterns, + searchPath: userRecentSearches.searchPath, + maxDepth: userRecentSearches.maxDepth + }) + .from(userRecentSearches) + .where( + and(eq(userRecentSearches.userId, userId), eq(userRecentSearches.connectionId, connectionId)) + ) + .orderBy(desc(userRecentSearches.updatedAt)); + + log.debug({ user_id: userId, connection_id: connectionId }, 'listed recent storage searches'); + return rows; +} + +/** + * Identity of a recent search: a SHA-256 hash of the sorted buckets, query and + * advanced options. Used as the unique key instead of the raw values, which + * can be too large for a btree index. + */ +export function recentSearchKey( + sortedBuckets: string[], + query: string, + options: RecentSearchOptions +): string { + const identity = JSON.stringify([ + sortedBuckets, + query, + options.useRegex, + options.excludePatterns, + options.searchPath, + options.maxDepth + ]); + return createHash('sha256').update(identity, 'utf8').digest('hex'); +} + +/** + * Record a search for a user and connection. If an identical search — same + * buckets, query and advanced options — already exists it is bumped to the top, + * otherwise a new row is inserted. Afterwards the history is pruned down to + * RECENT_SEARCHES_CAP entries. + * + * `buckets` is stored sorted so the unique key is independent of the order in + * which the user selected the buckets. + */ +export async function recordRecentSearch( + userId: string, + connectionId: string, + buckets: string[], + query: string, + options: RecentSearchOptions +): Promise { + const sortedBuckets = [...new Set(buckets)].sort(); + const searchKey = recentSearchKey(sortedBuckets, query, options); + await db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${JSON.stringify([userId, connectionId])}, 0))` + ); + const now = sql`clock_timestamp()`; + + // Upsert on the unique (user, connection, search key) key. + await tx + .insert(userRecentSearches) + .values({ + userId, + connectionId, + buckets: sortedBuckets, + query, + useRegex: options.useRegex, + excludePatterns: options.excludePatterns, + searchPath: options.searchPath, + maxDepth: options.maxDepth, + searchKey, + updatedAt: now + }) + .onConflictDoUpdate({ + target: [ + userRecentSearches.userId, + userRecentSearches.connectionId, + userRecentSearches.searchKey + ], + set: { updatedAt: now } + }); + + // Prune to the most recent RECENT_SEARCHES_CAP rows for this user and connection. + const keepIds = tx + .select({ id: userRecentSearches.id }) + .from(userRecentSearches) + .where( + and( + eq(userRecentSearches.userId, userId), + eq(userRecentSearches.connectionId, connectionId) + ) + ) + .orderBy(desc(userRecentSearches.updatedAt)) + .limit(RECENT_SEARCHES_CAP); + + await tx + .delete(userRecentSearches) + .where( + and( + eq(userRecentSearches.userId, userId), + eq(userRecentSearches.connectionId, connectionId), + notInArray(userRecentSearches.id, keepIds) + ) + ); + }); + log.info({ user_id: userId, connection_id: connectionId }, 'recorded recent storage search'); +} + +/** + * Remove all recent searches for a user and connection. + */ +export async function clearRecentSearches(userId: string, connectionId: string): Promise { + await db + .delete(userRecentSearches) + .where( + and(eq(userRecentSearches.userId, userId), eq(userRecentSearches.connectionId, connectionId)) + ); + log.debug({ user_id: userId, connection_id: connectionId }, 'cleared recent storage searches'); +} diff --git a/src/lib/server/storage/request-context.test.ts b/src/lib/server/storage/request-context.test.ts new file mode 100644 index 00000000..a55ca4b3 --- /dev/null +++ b/src/lib/server/storage/request-context.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import type { RequestEvent } from '@sveltejs/kit'; +import { requireObjectKey } from './request-context.js'; + +function eventWithQuery(params: Record): RequestEvent { + const url = new URL('http://localhost/api/storage/details'); + for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value); + return { url } as RequestEvent; +} + +describe('requireObjectKey', () => { + it('returns the key unchanged, including characters S3 accepts', () => { + const key = ' reports?2024/../a\\b/new.txt '; + expect(requireObjectKey(eventWithQuery({ key }))).toBe(key); + }); + + it('rejects a missing key with 400', () => { + expect(() => requireObjectKey(eventWithQuery({}))).toThrow( + expect.objectContaining({ + status: 400, + body: { message: 'Missing required query parameter: key' } + }) + ); + }); + + it('rejects an empty key with 400', () => { + expect(() => requireObjectKey(eventWithQuery({ key: '' }))).toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it.each(['tab\tname', 'new\nline', 'carriage\rreturn', 'start\u0001of heading'])( + 'accepts the control-character key %j, as S3 does', + (key) => { + expect(requireObjectKey(eventWithQuery({ key }))).toBe(key); + } + ); + + it('rejects a key longer than 1024 bytes with 400', () => { + expect(() => requireObjectKey(eventWithQuery({ key: 'a'.repeat(1025) }))).toThrow( + expect.objectContaining({ status: 400, body: { message: 'Invalid query parameter: key' } }) + ); + }); +}); diff --git a/src/lib/server/storage/request-context.ts b/src/lib/server/storage/request-context.ts new file mode 100644 index 00000000..bb9d0c5c --- /dev/null +++ b/src/lib/server/storage/request-context.ts @@ -0,0 +1,51 @@ +import { error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { getProvider } from './utils.js'; +import { withStorageHttpErrors } from './wrap-provider.js'; +import type { StorageProvider } from './provider.js'; +import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; +import { StorageObjectKeySchema } from '$lib/storage/schemas.js'; + +export function requireBucket(event: RequestEvent): string { + const bucket = event.url.searchParams.get('bucket')?.trim(); + if (!bucket) throw error(400, 'Missing required query parameter: bucket'); + return bucket; +} + +/** The `key` query parameter, validated as an S3 object key. */ +export function requireObjectKey(event: RequestEvent): string { + const key = event.url.searchParams.get('key'); + if (!key) throw error(400, 'Missing required query parameter: key'); + const parsed = StorageObjectKeySchema.safeParse(key); + if (!parsed.success) throw error(400, 'Invalid query parameter: key'); + return parsed.data; +} + +export function requireConfig(event: RequestEvent) { + const config = event.locals.storageConfig; + if (!config) throw error(401, 'No storage connection configured'); + return config; +} + +/** The connection id is captured for a manifest, never its decrypted config. */ +export function requireStorageConnectionId(event: RequestEvent): string { + const connectionId = event.request.headers.get(STORAGE_CONNECTION_ID_HEADER); + if (!connectionId) throw error(401, 'No storage connection configured'); + return connectionId; +} + +export function createStorageProvider(event: RequestEvent): { + provider: StorageProvider; + bucket: string; +} { + const config = requireConfig(event); + const bucket = requireBucket(event); + return { provider: withStorageHttpErrors(getProvider(config, bucket)), bucket }; +} + +export function createStorageProviderForBucket( + event: RequestEvent, + bucket: string +): StorageProvider { + return withStorageHttpErrors(getProvider(requireConfig(event), bucket)); +} diff --git a/src/lib/server/storage/s3-client.test.ts b/src/lib/server/storage/s3-client.test.ts index 9dd465bd..b9a155f8 100644 --- a/src/lib/server/storage/s3-client.test.ts +++ b/src/lib/server/storage/s3-client.test.ts @@ -1,6 +1,13 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { S3ConnectionConfig } from './types.js'; +const { mockEnv } = vi.hoisted(() => ({ mockEnv: {} as Record })); +vi.mock('$env/dynamic/private', () => ({ env: mockEnv })); +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + vi.mock('@aws-sdk/client-s3', () => ({ S3Client: vi.fn() })); @@ -11,7 +18,14 @@ vi.mock('@smithy/node-http-handler', () => ({ import { createS3Client } from './s3-client.js'; import { S3Client } from '@aws-sdk/client-s3'; +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import type { Agent } from 'https'; const mockS3Client = vi.mocked(S3Client); +const mockHandler = vi.mocked(NodeHttpHandler); + +function handlerAgents(call: number) { + return mockHandler.mock.calls[call]![0] as { httpAgent: Agent; httpsAgent: Agent }; +} function baseConfig(overrides: Partial = {}): S3ConnectionConfig { return { @@ -24,40 +38,82 @@ function baseConfig(overrides: Partial = {}): S3ConnectionCo } describe('createS3Client', () => { + it('shares keep-alive agents across clients so idle connections are reused', () => { + mockHandler.mockClear(); + createS3Client(baseConfig({ tls: { verification: 'Full' } })); + createS3Client(baseConfig({ host: 'other.example.com', tls: { verification: 'Full' } })); + expect(handlerAgents(0).httpAgent).toBe(handlerAgents(1).httpAgent); + expect(handlerAgents(0).httpsAgent).toBe(handlerAgents(1).httpsAgent); + expect(handlerAgents(0).httpsAgent.options.keepAlive).toBe(true); + expect(handlerAgents(0).httpsAgent.options.rejectUnauthorized).not.toBe(false); + }); + + it('uses a separate unverified agent when TLS verification is disabled', () => { + mockHandler.mockClear(); + createS3Client(baseConfig({ tls: { verification: 'Full' } })); + createS3Client(baseConfig({ tls: { verification: 'None' } })); + expect(handlerAgents(1).httpsAgent).not.toBe(handlerAgents(0).httpsAgent); + expect(handlerAgents(1).httpsAgent.options.rejectUnauthorized).toBe(false); + }); + it('builds an http endpoint when tls is absent', () => { createS3Client(baseConfig({ tls: undefined })); expect(mockS3Client).toHaveBeenCalledWith( - expect.objectContaining({ endpoint: 'http://minio.example.com' }) + expect.objectContaining({ + endpoint: 'http://minio.example.com', + requestChecksumCalculation: 'WHEN_REQUIRED' + }) ); }); it('builds an https endpoint when tls is present', () => { createS3Client(baseConfig({ tls: { verification: 'Full' } })); expect(mockS3Client).toHaveBeenCalledWith( - expect.objectContaining({ endpoint: 'https://minio.example.com' }) + expect.objectContaining({ + endpoint: 'https://minio.example.com', + requestChecksumCalculation: 'WHEN_REQUIRED' + }) ); }); it('includes port in the endpoint URL when port is set', () => { createS3Client(baseConfig({ port: 9000, tls: undefined })); expect(mockS3Client).toHaveBeenCalledWith( - expect.objectContaining({ endpoint: 'http://minio.example.com:9000' }) + expect.objectContaining({ + endpoint: 'http://minio.example.com:9000', + requestChecksumCalculation: 'WHEN_REQUIRED' + }) ); }); it('sets forcePathStyle=true for Path access style', () => { createS3Client(baseConfig({ accessStyle: 'Path' })); - expect(mockS3Client).toHaveBeenCalledWith(expect.objectContaining({ forcePathStyle: true })); + expect(mockS3Client).toHaveBeenCalledWith( + expect.objectContaining({ + forcePathStyle: true, + requestChecksumCalculation: 'WHEN_REQUIRED' + }) + ); }); it('sets forcePathStyle=false for VirtualHosted access style', () => { createS3Client(baseConfig({ accessStyle: 'VirtualHosted' })); - expect(mockS3Client).toHaveBeenCalledWith(expect.objectContaining({ forcePathStyle: false })); + expect(mockS3Client).toHaveBeenCalledWith( + expect.objectContaining({ + forcePathStyle: false, + requestChecksumCalculation: 'WHEN_REQUIRED' + }) + ); }); it('passes region name to the SDK', () => { createS3Client(baseConfig({ region: { name: 'eu-west-1' } })); - expect(mockS3Client).toHaveBeenCalledWith(expect.objectContaining({ region: 'eu-west-1' })); + expect(mockS3Client).toHaveBeenCalledWith( + expect.objectContaining({ + region: 'eu-west-1', + requestChecksumCalculation: 'WHEN_REQUIRED' + }) + ); }); it('sets credentials when accessKey and secretKey are provided', () => { @@ -76,3 +132,54 @@ describe('createS3Client', () => { expect(call.credentials).toBeUndefined(); }); }); + +describe('createS3Client custom CA certificate', () => { + let dir: string | undefined; + + afterEach(() => { + delete mockEnv.STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT; + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + /** Imports a fresh copy of the module so it reads the current environment. */ + async function freshClient() { + vi.resetModules(); + const { createS3Client: create } = await import('./s3-client.js'); + const { NodeHttpHandler: handler } = await import('@smithy/node-http-handler'); + return { create, handler: vi.mocked(handler) }; + } + + function lastHttpsAgent(handler: typeof mockHandler) { + return (handler.mock.calls.at(-1)![0] as { httpsAgent: Agent }).httpsAgent; + } + + it('uses the bundled CAs when no CA certificate is configured', async () => { + const { create, handler } = await freshClient(); + create(baseConfig({ tls: { verification: 'Full' } })); + expect(lastHttpsAgent(handler).options.ca).toBeUndefined(); + }); + + it('trusts only the configured CA certificate for verified connections', async () => { + dir = mkdtempSync(join(tmpdir(), 's3-ca-')); + const caPath = join(dir, 'ca.crt'); + // eslint-disable-next-line security/detect-non-literal-fs-filename + writeFileSync(caPath, 'test-ca'); + mockEnv.STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT = caPath; + + const { create, handler } = await freshClient(); + create(baseConfig({ tls: { verification: 'Full' } })); + expect(lastHttpsAgent(handler).options.ca?.toString()).toBe('test-ca'); + + create(baseConfig({ tls: { verification: 'None' } })); + expect(lastHttpsAgent(handler).options.ca).toBeUndefined(); + expect(lastHttpsAgent(handler).options.rejectUnauthorized).toBe(false); + }); + + it('fails with the variable name when the CA certificate cannot be read', async () => { + mockEnv.STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT = '/nonexistent/ca.crt'; + await expect(freshClient()).rejects.toThrow( + /STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT \(\/nonexistent\/ca\.crt\)/ + ); + }); +}); diff --git a/src/lib/server/storage/s3-client.ts b/src/lib/server/storage/s3-client.ts index 3b22292a..5aba9972 100644 --- a/src/lib/server/storage/s3-client.ts +++ b/src/lib/server/storage/s3-client.ts @@ -1,8 +1,45 @@ +import { env } from '$env/dynamic/private'; +import { readFileSync } from 'node:fs'; import { S3Client } from '@aws-sdk/client-s3'; import { NodeHttpHandler } from '@smithy/node-http-handler'; -import { Agent } from 'https'; +import { Agent as HttpAgent } from 'http'; +import { Agent as HttpsAgent } from 'https'; +import { logger } from '$lib/server/logging'; import type { S3ConnectionConfig } from './types.js'; +const log = logger.child({ module: 's3-client' }); + +/** + * Reads the CA certificate for verified S3 connections. When set, it replaces the CAs + * bundled with Node.js, like the Trino and database CA settings. + */ +function loadCaCert(): Buffer | undefined { + const path = env.STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT?.trim(); + if (!path) return undefined; + let ca: Buffer; + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + ca = readFileSync(path); + } catch (err) { + throw new Error( + `Cannot read STACKABLE_COCKPIT_STORAGE_TLS_CA_CERT (${path}): ${(err as Error).message}`, + { cause: err } + ); + } + log.info({ ca_cert_path: path }, 'using custom CA certificate for S3 connections'); + return ca; +} + +// A client is created per request. Sharing the agents lets those clients reuse +// pooled connections instead of each leaving its own idle keep-alive sockets +// open, which exhausted file descriptors on both the app and the S3 server. +// `timeout` only closes idle pooled sockets; active streams are unaffected. +const agentOptions = { keepAlive: true, maxFreeSockets: 64, timeout: 30_000 }; +const caCert = loadCaCert(); +const httpAgent = new HttpAgent(agentOptions); +const httpsAgent = new HttpsAgent({ ...agentOptions, ...(caCert && { ca: caCert }) }); +const unverifiedHttpsAgent = new HttpsAgent({ ...agentOptions, rejectUnauthorized: false }); + function buildEndpointUrl(config: S3ConnectionConfig): string { const scheme = config.tls ? 'https' : 'http'; const portSuffix = config.port ? `:${config.port}` : ''; @@ -12,14 +49,13 @@ function buildEndpointUrl(config: S3ConnectionConfig): string { export function createS3Client(config: S3ConnectionConfig): S3Client { return new S3Client({ region: config.region.name, + requestChecksumCalculation: 'WHEN_REQUIRED', endpoint: buildEndpointUrl(config), forcePathStyle: config.accessStyle === 'Path', - ...(config.tls?.verification === 'None' && { - requestHandler: new NodeHttpHandler({ - httpsAgent: new Agent({ rejectUnauthorized: false }) - }) + requestHandler: new NodeHttpHandler({ + httpAgent, + httpsAgent: config.tls?.verification === 'None' ? unverifiedHttpsAgent : httpsAgent }), - requestChecksumCalculation: 'WHEN_REQUIRED', ...(config.credentials && { credentials: { accessKeyId: config.credentials.accessKey, diff --git a/src/lib/server/storage/s3-errors.test.ts b/src/lib/server/storage/s3-errors.test.ts index 2abace02..48f78852 100644 --- a/src/lib/server/storage/s3-errors.test.ts +++ b/src/lib/server/storage/s3-errors.test.ts @@ -2,9 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { S3ServiceException } from '@aws-sdk/client-s3'; import { mapS3ErrorToHttp } from './s3-errors.js'; -vi.mock('$lib/server/logging', () => ({ - logger: { child: () => ({ warn: vi.fn(), info: vi.fn(), debug: vi.fn() }) } -})); +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); function makeS3Error(name: string, httpStatusCode?: number): S3ServiceException { const err = new S3ServiceException({ diff --git a/src/lib/server/storage/s3-provider-bucket-info.test.ts b/src/lib/server/storage/s3-provider-bucket-info.test.ts new file mode 100644 index 00000000..c0742d1c --- /dev/null +++ b/src/lib/server/storage/s3-provider-bucket-info.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest'; +import { S3ServiceException, type S3Client } from '@aws-sdk/client-s3'; +import { isHttpError } from '@sveltejs/kit'; + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + +// Mock createS3Client — we inject a fake client directly so this is never called +vi.mock('./s3-client.js', () => ({ + createS3Client: vi.fn().mockReturnValue({ send: vi.fn() }) +})); + +import { S3StorageProvider } from './s3-provider.js'; + +function makeS3Error(name: string, httpStatusCode?: number): S3ServiceException { + const err = new S3ServiceException({ + name, + message: `${name} error`, + $fault: 'client', + $metadata: { httpStatusCode } + }); + err.name = name; + return err; +} + +function makeProvider() { + const send = vi.fn(); + const client = { send } as unknown as S3Client; + const config = { + type: 's3' as const, + host: 'minio.example.com', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' }, + bucket: 'test-bucket' + }; + return { provider: new S3StorageProvider(config, client), send }; +} + +const failures = [ + ['AccessDenied', 403], + ['NotImplemented', 501] +] as const; + +// --------------------------------------------------------------------------- +// Bucket info getters: "not configured" is empty, any other failure is null +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.getBucketVersioning', () => { + it.each([ + ['Enabled', 'Enabled'], + ['Suspended', 'Suspended'], + [undefined, 'Disabled'] + ])('maps status %s to %s', async (status, expected) => { + const { provider, send } = makeProvider(); + send.mockResolvedValue({ Status: status }); + expect(await provider.getBucketVersioning()).toBe(expected); + }); + + it.each(failures)('returns null on %s', async (name, status) => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error(name, status)); + expect(await provider.getBucketVersioning()).toBeNull(); + }); + + it('returns null on a non-S3 error', async () => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(new Error('socket hang up')); + expect(await provider.getBucketVersioning()).toBeNull(); + }); +}); + +describe('S3StorageProvider.getBucketLifecycleRules', () => { + it('maps configured rules', async () => { + const { provider, send } = makeProvider(); + send.mockResolvedValue({ + Rules: [{ ID: 'expire', Status: 'Enabled', Expiration: { Days: 30 } }] + }); + const rules = await provider.getBucketLifecycleRules(); + expect(rules).toHaveLength(1); + expect(rules?.[0]).toMatchObject({ id: 'expire', status: 'Enabled' }); + expect(rules?.[0].expirations).toEqual([ + { days: 30, date: undefined, expiredObjectDeleteMarker: undefined } + ]); + }); + + it('returns an empty list when no lifecycle configuration exists', async () => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error('NoSuchLifecycleConfiguration', 404)); + expect(await provider.getBucketLifecycleRules()).toEqual([]); + }); + + it.each(failures)('returns null on %s', async (name, status) => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error(name, status)); + expect(await provider.getBucketLifecycleRules()).toBeNull(); + }); +}); + +describe('S3StorageProvider.getBucketTags', () => { + it('maps the tag set', async () => { + const { provider, send } = makeProvider(); + send.mockResolvedValue({ TagSet: [{ Key: 'team', Value: 'data' }] }); + expect(await provider.getBucketTags()).toEqual({ team: 'data' }); + }); + + it('returns no tags when the bucket has no tag set', async () => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error('NoSuchTagSet', 404)); + expect(await provider.getBucketTags()).toEqual({}); + }); + + it.each(failures)('returns null on %s', async (name, status) => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error(name, status)); + expect(await provider.getBucketTags()).toBeNull(); + }); +}); + +describe('S3StorageProvider.getBucketAcl', () => { + it('maps owner and grants', async () => { + const { provider, send } = makeProvider(); + send.mockResolvedValue({ + Owner: { DisplayName: 'alice', ID: 'abc' }, + Grants: [{ Grantee: { ID: 'abc' }, Permission: 'FULL_CONTROL' }] + }); + expect(await provider.getBucketAcl()).toEqual({ + owner: 'alice / abc', + grants: [{ grantee: 'abc', permission: 'FULL_CONTROL' }] + }); + }); + + it.each(failures)('returns null on %s', async (name, status) => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error(name, status)); + expect(await provider.getBucketAcl()).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Zero-length putObject maps S3 errors like the multipart path +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.putObject (empty object)', () => { + it.each([ + ['AccessDenied', 403, 403], + ['NoSuchBucket', 404, 404], + ['InternalError', 500, 502] + ])('maps %s to HTTP %i', async (name, s3Status, httpStatus) => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error(name, s3Status)); + + const err = await provider + .putObject('empty.txt', Buffer.alloc(0), 'text/plain', 0) + .catch((e: unknown) => e); + + expect(isHttpError(err)).toBe(true); + expect((err as { status: number }).status).toBe(httpStatus); + }); +}); diff --git a/src/lib/server/storage/s3-provider-copy.test.ts b/src/lib/server/storage/s3-provider-copy.test.ts new file mode 100644 index 00000000..f3008530 --- /dev/null +++ b/src/lib/server/storage/s3-provider-copy.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { S3Client } from '@aws-sdk/client-s3'; + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + +// Mock createS3Client — we inject a fake client directly so this is never called +vi.mock('./s3-client.js', () => ({ + createS3Client: vi.fn().mockReturnValue({ send: vi.fn() }) +})); + +import { S3StorageProvider } from './s3-provider.js'; + +function makeProvider() { + const send = vi.fn(); + const client = { send } as unknown as S3Client; + const config = { + type: 's3' as const, + host: 'minio.example.com', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' }, + bucket: 'test-bucket' + }; + return { provider: new S3StorageProvider(config, client), send }; +} + +// --------------------------------------------------------------------------- +// copyObject across buckets and with cancellation +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.copyObject options', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + }); + + it('reads the source from another bucket of the same connection', async () => { + send.mockResolvedValueOnce({ ContentLength: 10 }); + send.mockResolvedValueOnce({}); + + await provider.copyObject('a b.txt', 'dst/a b.txt', { sourceBucket: 'other-bucket' }); + + expect(send.mock.calls[0][0].input).toEqual({ Bucket: 'other-bucket', Key: 'a b.txt' }); + expect(send.mock.calls[1][0].input).toEqual({ + Bucket: 'test-bucket', + CopySource: '/other-bucket/a%20b.txt', + Key: 'dst/a b.txt' + }); + }); + + it('passes the abort signal to every request', async () => { + const signal = new AbortController().signal; + send.mockResolvedValueOnce({ ContentLength: 10 }); + send.mockResolvedValueOnce({}); + + await provider.copyObject('a.txt', 'b.txt', { signal }); + + expect(send.mock.calls.map((call) => call[1])).toEqual([ + { abortSignal: signal }, + { abortSignal: signal } + ]); + }); + + it('cleans up an upload whose creation raced with a cancel', async () => { + const controller = new AbortController(); + send.mockResolvedValueOnce({ ContentLength: 6 * 1024 * 1024 * 1024 }); + send.mockImplementationOnce(async () => { + controller.abort(); + return { UploadId: 'upload-4' }; + }); + send.mockResolvedValueOnce({}); + + await expect( + provider.copyObject('src/big', 'dst/big', { signal: controller.signal }) + ).rejects.toThrow(); + + // The creation itself is never aborted, so its upload ID is known and released. + expect(send.mock.calls[1][1]).toBeUndefined(); + expect(send.mock.calls).toHaveLength(3); + expect(send.mock.calls[2][0].constructor.name).toBe('AbortMultipartUploadCommand'); + expect(send.mock.calls[2][0].input).toMatchObject({ UploadId: 'upload-4' }); + }); + + it('pins every part of a multipart copy to the source ETag', async () => { + send.mockResolvedValueOnce({ ContentLength: 6 * 1024 * 1024 * 1024, ETag: '"v1"' }); + send.mockResolvedValueOnce({ UploadId: 'upload-5' }); + send.mockResolvedValue({ CopyPartResult: { ETag: '"part"' } }); + + await provider.copyObject('src/big', 'dst/big'); + + const partCopies = send.mock.calls + .map((call) => call[0]) + .filter((cmd) => cmd.constructor.name === 'UploadPartCopyCommand'); + expect(partCopies).toHaveLength(24); + for (const cmd of partCopies) { + expect(cmd.input.CopySourceIfMatch).toBe('"v1"'); + } + }); + + it('fails and releases the upload when the source changes mid-copy', async () => { + send.mockResolvedValueOnce({ ContentLength: 6 * 1024 * 1024 * 1024, ETag: '"v1"' }); + send.mockResolvedValueOnce({ UploadId: 'upload-6' }); + send.mockResolvedValueOnce({ CopyPartResult: { ETag: '"part-1"' } }); + send.mockRejectedValueOnce( + Object.assign(new Error('At least one of the pre-conditions you specified did not hold'), { + name: 'PreconditionFailed', + $fault: 'client', + $metadata: { httpStatusCode: 412 } + }) + ); + send.mockResolvedValueOnce({}); + + await expect(provider.copyObject('src/big', 'dst/big')).rejects.toThrow(); + + const commands = send.mock.calls.map((call) => call[0].constructor.name); + expect(commands).not.toContain('CompleteMultipartUploadCommand'); + expect(commands.at(-1)).toBe('AbortMultipartUploadCommand'); + expect(send.mock.calls.at(-1)?.[0].input).toMatchObject({ UploadId: 'upload-6' }); + }); + + it('aborts a cancelled multipart copy without the cancelled signal', async () => { + const controller = new AbortController(); + send.mockResolvedValueOnce({ ContentLength: 6 * 1024 * 1024 * 1024 }); + send.mockResolvedValueOnce({ UploadId: 'upload-3' }); + send.mockImplementationOnce(() => { + controller.abort(); + return Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + send.mockResolvedValueOnce({}); + + await expect( + provider.copyObject('src/big', 'dst/big', { signal: controller.signal }) + ).rejects.toThrow('aborted'); + + const [abortCmd, abortOptions] = send.mock.calls[3]; + expect(abortCmd.constructor.name).toBe('AbortMultipartUploadCommand'); + expect(abortCmd.input).toEqual({ Bucket: 'test-bucket', Key: 'dst/big', UploadId: 'upload-3' }); + expect(abortOptions).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// prefixExists +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.prefixExists', () => { + it('lists one key without a delimiter so a bare folder marker counts', async () => { + const { provider, send } = makeProvider(); + send.mockResolvedValueOnce({ Contents: [{ Key: 'docs/' }] }); + + expect(await provider.prefixExists('docs/')).toBe(true); + expect(send.mock.calls[0][0].input).toEqual({ + Bucket: 'test-bucket', + Prefix: 'docs/', + MaxKeys: 1, + EncodingType: 'url' + }); + }); + + it('reports a prefix without objects as missing', async () => { + const { provider, send } = makeProvider(); + send.mockResolvedValueOnce({}); + + expect(await provider.prefixExists('docs/')).toBe(false); + }); +}); diff --git a/src/lib/server/storage/s3-provider-delete.test.ts b/src/lib/server/storage/s3-provider-delete.test.ts new file mode 100644 index 00000000..c30b27fe --- /dev/null +++ b/src/lib/server/storage/s3-provider-delete.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { S3Client } from '@aws-sdk/client-s3'; +import { + S3ServiceException, + ListObjectsV2Command, + DeleteObjectsCommand, + DeleteObjectCommand +} from '@aws-sdk/client-s3'; + +vi.mock('$lib/server/logging', () => ({ + logger: { child: () => ({ trace: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }) } +})); + +vi.mock('./s3-client.js', () => ({ + createS3Client: vi.fn().mockReturnValue({ send: vi.fn() }) +})); + +import { S3StorageProvider } from './s3-provider.js'; + +function makeProvider() { + const send = vi.fn(); + const client = { send } as unknown as S3Client; + const config = { + type: 's3' as const, + host: 'minio.example.com', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' }, + bucket: 'test-bucket' + }; + const provider = new S3StorageProvider(config, client); + return { provider, send }; +} + +function keys(prefix: string, count: number) { + return Array.from({ length: count }, (_, i) => ({ Key: `${prefix}${i}` })); +} + +/** The command type of every `send` call, in order. */ +function commandSequence(send: ReturnType): string[] { + return send.mock.calls.map(([command]) => { + if (command instanceof ListObjectsV2Command) return 'list'; + if (command instanceof DeleteObjectsCommand) return 'delete-batch'; + if (command instanceof DeleteObjectCommand) return 'delete-single'; + return 'other'; + }); +} + +describe('S3StorageProvider.deleteObjects streaming directory expansion', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + }); + + it('deletes each full chunk before listing the next page', async () => { + send.mockImplementation(async (command) => { + if (command instanceof DeleteObjectsCommand) return { Errors: [] }; + return command.input.ContinuationToken + ? { Contents: keys('dir/b', 5), IsTruncated: false } + : { Contents: keys('dir/a', 1000), IsTruncated: true, NextContinuationToken: 'token-1' }; + }); + + const result = await provider.deleteObjects(['dir/'], true); + + expect(commandSequence(send)).toEqual(['list', 'delete-batch', 'list', 'delete-batch']); + expect(send.mock.calls[1][0].input.Delete.Objects).toHaveLength(1000); + expect(send.mock.calls[3][0].input.Delete.Objects).toEqual(keys('dir/b', 5)); + expect(result).toEqual({ failed: [] }); + }); + + it('never sends more than 1000 keys in one DeleteObjects request', async () => { + send.mockImplementation(async (command) => { + if (command instanceof DeleteObjectsCommand) return { Errors: [] }; + return { Contents: keys('dir/', 999), IsTruncated: false }; + }); + + await provider.deleteObjects(['a.txt', 'b.txt', 'dir/'], true); + + const sizes = send.mock.calls + .map(([command]) => command) + .filter((command) => command instanceof DeleteObjectsCommand) + .map((command) => command.input.Delete!.Objects!.length); + expect(sizes).toEqual([1000, 1]); + }); + + it('collects failures from every chunk', async () => { + send.mockImplementation(async (command) => { + if (command instanceof DeleteObjectsCommand) { + const [first] = command.input.Delete!.Objects!; + return { Errors: [{ Key: first.Key, Code: 'AccessDenied', Message: 'No permission' }] }; + } + return command.input.ContinuationToken + ? { Contents: keys('dir/b', 1), IsTruncated: false } + : { Contents: keys('dir/a', 1000), IsTruncated: true, NextContinuationToken: 'token-1' }; + }); + + const result = await provider.deleteObjects(['dir/'], true); + + expect(result.failed.map((f) => f.key)).toEqual(['dir/a0', 'dir/b0']); + }); + + it('deletes keys XML cannot carry inside a directory one by one', async () => { + send.mockImplementation(async (command) => { + if (command instanceof ListObjectsV2Command) { + return { Contents: [{ Key: 'dir/ok' }, { Key: 'dir/bad\u0001' }], IsTruncated: false }; + } + return command instanceof DeleteObjectsCommand ? { Errors: [] } : {}; + }); + + await provider.deleteObjects(['dir/'], true); + + expect(commandSequence(send)).toEqual(['list', 'delete-single', 'delete-batch']); + expect(send.mock.calls[1][0].input.Key).toBe('dir/bad\u0001'); + expect(send.mock.calls[2][0].input.Delete.Objects).toEqual([{ Key: 'dir/ok' }]); + }); + + it('maps an access-denied listing to HTTP 403', async () => { + send.mockRejectedValue( + new S3ServiceException({ + name: 'AccessDenied', + $fault: 'client', + $metadata: { httpStatusCode: 403 }, + message: 'Access denied' + }) + ); + + await expect(provider.deleteObjects(['dir/'], true)).rejects.toMatchObject({ status: 403 }); + }); + + it('keeps chunks deleted before a later listing page fails', async () => { + const denied = new S3ServiceException({ + name: 'AccessDenied', + $fault: 'client', + $metadata: { httpStatusCode: 403 }, + message: 'Access denied' + }); + send.mockImplementation(async (command) => { + if (command instanceof DeleteObjectsCommand) return { Errors: [] }; + if (command.input.ContinuationToken) throw denied; + return { Contents: keys('dir/a', 1000), IsTruncated: true, NextContinuationToken: 'token-1' }; + }); + + await expect(provider.deleteObjects(['dir/'], true)).rejects.toMatchObject({ status: 403 }); + expect(commandSequence(send)).toEqual(['list', 'delete-batch', 'list']); + }); + + it('does not expand directories when expansion is off', async () => { + send.mockResolvedValue({ Errors: [] }); + + await provider.deleteObjects(['dir/'], false); + + expect(commandSequence(send)).toEqual(['delete-batch']); + expect(send.mock.calls[0][0].input.Delete.Objects).toEqual([{ Key: 'dir/' }]); + }); +}); diff --git a/src/lib/server/storage/s3-provider-encoding.test.ts b/src/lib/server/storage/s3-provider-encoding.test.ts new file mode 100644 index 00000000..c1d363b9 --- /dev/null +++ b/src/lib/server/storage/s3-provider-encoding.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + DeleteObjectCommand, + DeleteObjectsCommand, + S3ServiceException, + type S3Client +} from '@aws-sdk/client-s3'; + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + +vi.mock('./s3-client.js', () => ({ + createS3Client: vi.fn().mockReturnValue({ send: vi.fn() }) +})); + +import { S3StorageProvider } from './s3-provider.js'; + +function makeProvider() { + const send = vi.fn(); + const client = { send } as unknown as S3Client; + const provider = new S3StorageProvider( + { + type: 's3', + host: 'garage.example.com', + accessStyle: 'Path', + region: { name: 'us-east-1' }, + bucket: 'test-bucket' + }, + client + ); + return { provider, send }; +} + +async function listRecursively(provider: S3StorageProvider, prefix: string): Promise { + const keys: string[] = []; + await provider.listAllKeysProgressively(prefix, (batch) => { + keys.push(...batch.map((item) => item.key)); + }); + return keys; +} + +// Keys as S3 returns them with `EncodingType=url`: AWS form-encodes a space as `+` and a `+` as +// `%2B`; other implementations send a space as `%20`. +const encodedKeys = [ + ['dir/tab%09name.txt', 'dir/tab\tname.txt'], + ['dir/new%0Aline.txt', 'dir/new\nline.txt'], + ['dir/carriage%0Dreturn.txt', 'dir/carriage\rreturn.txt'], + ['dir/start%01of+heading.txt', 'dir/start\u0001of heading.txt'], + ['dir/space%20and%2Bplus.txt', 'dir/space and+plus.txt'], + ['dir/100%25.txt', 'dir/100%.txt'], + ['dir/%C3%A4%E2%82%AC.txt', 'dir/ä€.txt'] +] as const; + +describe('S3StorageProvider listing encoding', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + }); + + it('requests url-encoded listings and decodes keys and prefixes in listObjects', async () => { + send.mockResolvedValue({ + CommonPrefixes: [{ Prefix: 'dir/sub%09folder+one/' }], + Contents: encodedKeys.map(([Key]) => ({ Key, Size: 1 })), + EncodingType: 'url', + IsTruncated: true, + NextContinuationToken: 'opaque+token%2F' + }); + + const page = await provider.listObjects('dir/', 50, 'previous+token%2F'); + + const input = send.mock.calls[0][0].input; + expect(input.EncodingType).toBe('url'); + expect(input.ContinuationToken).toBe('previous+token%2F'); + expect(page.objects.map((object) => object.key)).toEqual([ + 'dir/sub\tfolder one/', + ...encodedKeys.map(([, key]) => key) + ]); + expect(page.nextContinuationToken).toBe('opaque+token%2F'); + }); + + it('filters out the echoed prefix after decoding', async () => { + send.mockResolvedValue({ + Contents: [{ Key: 'my+dir/' }, { Key: 'my+dir/a.txt' }], + EncodingType: 'url', + IsTruncated: false + }); + + const page = await provider.listObjects('my dir/', 50); + + expect(page.objects.map((object) => object.key)).toEqual(['my dir/a.txt']); + }); + + it('keeps a malformed encoded key unchanged', async () => { + send.mockResolvedValue({ + Contents: [{ Key: 'broken%E0%A4%A.txt' }], + IsTruncated: false, + EncodingType: 'url' + }); + + const page = await provider.listObjects('', 50); + + expect(page.objects[0].key).toBe('broken%E0%A4%A.txt'); + }); + + it('requests url-encoded listings and decodes keys in recursive listings', async () => { + send + .mockResolvedValueOnce({ + Contents: [{ Key: encodedKeys[0][0] }], + EncodingType: 'url', + IsTruncated: true, + NextContinuationToken: 'next+token' + }) + .mockResolvedValueOnce({ + Contents: [{ Key: encodedKeys[4][0] }], + IsTruncated: false, + EncodingType: 'url' + }); + + const keys = await listRecursively(provider, 'dir/'); + + expect(send.mock.calls.map(([command]) => command.input.EncodingType)).toEqual(['url', 'url']); + expect(send.mock.calls[1][0].input.ContinuationToken).toBe('next+token'); + expect(keys).toEqual([encodedKeys[0][1], encodedKeys[4][1]]); + }); + + it('requests url-encoded listings and decodes keys when searching', async () => { + send.mockResolvedValue({ + Contents: encodedKeys.map(([Key]) => ({ Key, Size: 1 })), + EncodingType: 'url', + IsTruncated: false + }); + + const { results } = await provider.search('', { prefix: 'dir/' }); + + expect(send.mock.calls[0][0].input.EncodingType).toBe('url'); + expect(results.map((result) => result.key)).toEqual(encodedKeys.map(([, key]) => key)); + }); + + it('deletes directory members under their decoded keys', async () => { + send + .mockResolvedValueOnce({ + Contents: [{ Key: 'dir/a+b%0A.txt' }], + IsTruncated: false, + EncodingType: 'url' + }) + .mockResolvedValueOnce({}); + + await provider.deleteObjects(['dir/'], true); + + expect(send.mock.calls[1][0].input.Delete.Objects).toEqual([{ Key: 'dir/a b\n.txt' }]); + }); + + it('deletes keys XML cannot carry one by one and the rest in a batch', async () => { + const unsafeKeys = ['start\u0001of heading.txt', 'lone\uD800surrogate.txt']; + send.mockImplementation(async (command) => { + if (command instanceof DeleteObjectCommand && command.input.Key === unsafeKeys[1]) { + throw new S3ServiceException({ + name: 'InvalidArgument', + message: 'Invalid key', + $fault: 'client', + $metadata: { httpStatusCode: 400 } + }); + } + return {}; + }); + + const result = await provider.deleteObjects( + ['tab\tok.txt', ...unsafeKeys, 'new\nline.txt'], + true + ); + + const batches = send.mock.calls + .map(([command]) => command) + .filter((command) => command instanceof DeleteObjectsCommand); + expect(batches.map((command) => command.input.Delete?.Objects)).toEqual([ + [{ Key: 'tab\tok.txt' }, { Key: 'new\nline.txt' }] + ]); + const singles = send.mock.calls + .map(([command]) => command) + .filter((command) => command instanceof DeleteObjectCommand); + expect(singles.map((command) => command.input.Key)).toEqual(unsafeKeys); + expect(result.failed).toEqual([ + { key: unsafeKeys[1], code: 'InvalidArgument', message: 'Invalid key' } + ]); + }); + + it('leaves keys unchanged when the response does not echo the url encoding', async () => { + send.mockResolvedValue({ + CommonPrefixes: [{ Prefix: 'a+b%20dir/' }], + Contents: [{ Key: 'a+b' }, { Key: '100%25' }], + IsTruncated: false + }); + + const page = await provider.listObjects('', 50); + const keys = await listRecursively(provider, ''); + const { results } = await provider.search(''); + + expect(page.objects.map((object) => object.key)).toEqual(['a+b%20dir/', 'a+b', '100%25']); + expect(keys).toEqual(['a+b', '100%25']); + expect(results.map((result) => result.key)).toEqual(['a+b', '100%25']); + }); + + it('deletes keys XML cannot carry with at most four requests in flight', async () => { + const unsafeKeys = Array.from({ length: 10 }, (_, index) => `key\u0001${index}`); + let inFlight = 0; + let maxInFlight = 0; + send.mockImplementation(async (command) => { + if (!(command instanceof DeleteObjectCommand)) return {}; + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight -= 1; + if (command.input.Key === unsafeKeys[7]) { + throw new S3ServiceException({ + name: 'AccessDenied', + message: 'Access denied', + $fault: 'client', + $metadata: { httpStatusCode: 403 } + }); + } + return {}; + }); + + const result = await provider.deleteObjects(unsafeKeys, true); + + const deleted = send.mock.calls.map(([command]) => command.input.Key); + expect(deleted.sort()).toEqual([...unsafeKeys].sort()); + expect(maxInFlight).toBe(4); + expect(result.failed).toEqual([ + { key: unsafeKeys[7], code: 'AccessDenied', message: 'Access denied' } + ]); + }); +}); diff --git a/src/lib/server/storage/s3-provider-search.test.ts b/src/lib/server/storage/s3-provider-search.test.ts new file mode 100644 index 00000000..46c32910 --- /dev/null +++ b/src/lib/server/storage/s3-provider-search.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { S3Client } from '@aws-sdk/client-s3'; +import { S3ServiceException } from '@aws-sdk/client-s3'; + +vi.mock('$lib/server/logging', () => ({ + logger: { child: () => ({ trace: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }) } +})); + +// Mock createS3Client — we inject a fake client directly so this is rarely called +vi.mock('./s3-client.js', () => ({ + createS3Client: vi.fn().mockReturnValue({ send: vi.fn() }) +})); + +import { S3StorageProvider } from './s3-provider.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeProvider() { + const send = vi.fn(); + const client = { send } as unknown as S3Client; + const config = { + type: 's3' as const, + host: 'minio.example.com', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' }, + bucket: 'test-bucket' + }; + const provider = new S3StorageProvider(config, client); + return { provider, send }; +} + +// --------------------------------------------------------------------------- +// listAllKeysProgressively +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.listAllKeysProgressively', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + }); + + it('streams batches across pages and resolves', async () => { + send + .mockResolvedValueOnce({ + Contents: [{ Key: 'a.txt' }, { Key: 'b.txt' }], + IsTruncated: true, + NextContinuationToken: 'token-1' + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'c.txt' }], + IsTruncated: false + }); + + const seen: string[] = []; + await provider.listAllKeysProgressively('', (batch) => { + seen.push(...batch.map((k) => k.key)); + }); + + expect(seen).toEqual(['a.txt', 'b.txt', 'c.txt']); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('stops paging when the batch callback returns false', async () => { + send + .mockResolvedValueOnce({ + Contents: [{ Key: 'a.txt' }, { Key: 'b.txt' }], + IsTruncated: true, + NextContinuationToken: 'token-1' + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'c.txt' }], + IsTruncated: false + }); + + const seen: string[] = []; + await provider.listAllKeysProgressively('', (batch) => { + seen.push(...batch.map((k) => k.key)); + return false; + }); + + expect(seen).toEqual(['a.txt', 'b.txt']); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('lists recursively, without a delimiter', async () => { + send.mockResolvedValue({ IsTruncated: false }); + + await provider.listAllKeysProgressively('p/', () => {}); + + expect(send.mock.calls[0][0].input.Prefix).toBe('p/'); + expect(send.mock.calls[0][0].input.Delimiter).toBeUndefined(); + }); + + it('skips Contents entries with no Key and an empty final page', async () => { + send + .mockResolvedValueOnce({ + Contents: [{ Key: 'valid.txt' }, {}], + IsTruncated: true, + NextContinuationToken: 'token-1' + }) + .mockResolvedValueOnce({ IsTruncated: false }); + + const batches: string[][] = []; + await provider.listAllKeysProgressively('', (batch) => { + batches.push(batch.map((k) => k.key)); + }); + + expect(batches).toEqual([['valid.txt']]); + }); + + it('continues past an empty truncated page without an empty batch', async () => { + send + .mockResolvedValueOnce({ Contents: [], IsTruncated: true, NextContinuationToken: 'token-1' }) + .mockResolvedValueOnce({ Contents: [{ Key: 'a.txt' }], IsTruncated: false }); + + const batches: string[][] = []; + await provider.listAllKeysProgressively('', (batch) => { + batches.push(batch.map((k) => k.key)); + }); + + expect(batches).toEqual([['a.txt']]); + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[1][0].input.ContinuationToken).toBe('token-1'); + }); + + it('propagates an error thrown by the batch callback and stops paging', async () => { + send.mockResolvedValueOnce({ + Contents: [{ Key: 'a.txt' }], + IsTruncated: true, + NextContinuationToken: 'token-1' + }); + + const failure = new Error('stop'); + await expect( + provider.listAllKeysProgressively('', () => { + throw failure; + }) + ).rejects.toBe(failure); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('passes the abort signal to every page request', async () => { + send + .mockResolvedValueOnce({ + Contents: [{ Key: 'a.txt' }], + IsTruncated: true, + NextContinuationToken: 'token-1' + }) + .mockResolvedValueOnce({ Contents: [{ Key: 'b.txt' }], IsTruncated: false }); + + const { signal } = new AbortController(); + await provider.listAllKeysProgressively('', () => {}, { signal }); + + expect(send).toHaveBeenCalledTimes(2); + for (const call of send.mock.calls) { + expect(call[1]).toEqual({ abortSignal: signal }); + } + }); +}); + +// --------------------------------------------------------------------------- +// search +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.search', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + }); + + it('maps an S3 access-denied error to HTTP 403', async () => { + send.mockRejectedValue( + new S3ServiceException({ + name: 'AccessDenied', + $fault: 'client', + $metadata: { httpStatusCode: 403 }, + message: 'Access denied' + }) + ); + + await expect(provider.search('report')).rejects.toMatchObject({ status: 403 }); + }); + + it('matches case-insensitively anywhere in the full key path', async () => { + send.mockResolvedValue({ + Contents: [ + { Key: 'Reports/2026/Q3/final.pdf', Size: 100, LastModified: new Date('2026-01-01') }, + { Key: 'reports/2026/Q3/final.pdf', Size: 200, LastModified: new Date('2026-01-02') }, + { Key: 'notes/report.txt', Size: 50 }, + { Key: 'other/archive.zip', Size: 10 } + ], + IsTruncated: false + }); + + const result = await provider.search('report'); + + expect(result.results.map((r) => r.key)).toEqual([ + 'Reports/2026/Q3/final.pdf', + 'reports/2026/Q3/final.pdf', + 'notes/report.txt' + ]); + }); + + it('returns files and directories, flagging keys ending in "/"', async () => { + send.mockResolvedValue({ + Contents: [ + { Key: 'data/', Size: 0, LastModified: new Date('2026-02-01') }, + { Key: 'data/2026/', Size: 0 }, + { Key: 'data/2026/file.csv', Size: 42, LastModified: new Date('2026-02-02') } + ], + IsTruncated: false + }); + + const result = await provider.search('data'); + + expect(result.results).toHaveLength(3); + expect(result.results[0]).toMatchObject({ + key: 'data/', + isDirectory: true, + size: 0 + }); + expect(result.results[1]).toMatchObject({ key: 'data/2026/', isDirectory: true }); + expect(result.results[2]).toMatchObject({ + key: 'data/2026/file.csv', + isDirectory: false, + size: 42 + }); + }); + + it('falls back to epoch when lastModified is missing', async () => { + send.mockResolvedValue({ + Contents: [{ Key: 'plain.txt', Size: 1 }], + IsTruncated: false + }); + + const result = await provider.search('plain'); + expect(result.results[0].lastModified).toEqual(new Date(0)); + }); + + it('returns every matching result', async () => { + send.mockResolvedValue({ + Contents: Array.from({ length: 10 }, (_, i) => ({ Key: `match-${i}.txt`, Size: 1 })), + IsTruncated: false + }); + + const result = await provider.search('match'); + + expect(result.results).toHaveLength(10); + }); + + it('lists only the requested prefix and filters depth relative to it', async () => { + send.mockResolvedValue({ + Contents: [ + { Key: 'reports/final.pdf', Size: 1 }, + { Key: 'reports/2026/final.pdf', Size: 1 }, + { Key: 'reports/2026/q1/final.pdf', Size: 1 } + ], + IsTruncated: false + }); + + const result = await provider.search('final', { prefix: 'reports/', maxDepth: 2 }); + + expect(result.results.map((item) => item.key)).toEqual([ + 'reports/final.pdf', + 'reports/2026/final.pdf' + ]); + expect(send.mock.calls[0][0].input.Prefix).toBe('reports/'); + }); + + it('continues paging until all keys have been searched', async () => { + send.mockResolvedValueOnce({ + Contents: [{ Key: 'a.txt', Size: 1 }], + IsTruncated: true, + NextContinuationToken: 'token-1' + }); + send.mockResolvedValueOnce({ Contents: [{ Key: 'match.txt', Size: 1 }], IsTruncated: false }); + + const result = await provider.search('match'); + + expect(result.results).toHaveLength(1); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('uses defaults when options are omitted', async () => { + send.mockResolvedValue({ Contents: [], IsTruncated: false }); + + const result = await provider.search('x'); + + expect(result).toEqual({ results: [] }); + }); + + it('aborts before scanning when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(provider.search('x', { signal: controller.signal })).rejects.toMatchObject({ + name: 'AbortError' + }); + expect(send).not.toHaveBeenCalled(); + }); + + it('aborts mid-listing when the signal fires', async () => { + const controller = new AbortController(); + let callCount = 0; + send.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + Contents: [{ Key: 'a.txt', Size: 1 }], + IsTruncated: true, + NextContinuationToken: 'token-1' + }); + } + controller.abort(); + return Promise.resolve({ + Contents: [{ Key: 'b.txt', Size: 1 }], + IsTruncated: false + }); + }); + + await expect(provider.search('x', { signal: controller.signal })).rejects.toMatchObject({ + name: 'AbortError' + }); + }); +}); diff --git a/src/lib/server/storage/s3-provider.test.ts b/src/lib/server/storage/s3-provider.test.ts index a32bdc9d..2d4e868b 100644 --- a/src/lib/server/storage/s3-provider.test.ts +++ b/src/lib/server/storage/s3-provider.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { S3ServiceException } from '@aws-sdk/client-s3'; +import { + S3ServiceException, + PutObjectCommand, + CopyObjectCommand, + HeadObjectCommand +} from '@aws-sdk/client-s3'; -vi.mock('$lib/server/logging', () => ({ - logger: { child: () => ({ trace: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }) } -})); +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); // Hoist so the factory closure can reference them const { mockUploadDone, MockUpload } = vi.hoisted(() => { @@ -369,6 +372,36 @@ describe('S3StorageProvider.putObject', () => { expect(opts.queueSize).toBe(4); expect(opts.partSize).toBe(5 * 1024 * 1024); }); + + it('uses PutObjectCommand for empty files instead of multipart Upload', async () => { + const { send } = makeProvider(); + // Re-create provider with the send mock we can inspect + const client = { send } as unknown as import('@aws-sdk/client-s3').S3Client; + const config = { + type: 's3' as const, + host: 'localhost', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' }, + bucket: 'test-bucket' + }; + const emptyProvider = new S3StorageProvider(config, client); + send.mockResolvedValue({}); + + await emptyProvider.putObject('empty.txt', new ReadableStream(), 'text/plain', 0); + + expect(MockUpload).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledOnce(); + const cmd = send.mock.calls[0][0]; + expect(cmd).toBeInstanceOf(PutObjectCommand); + expect(cmd.input).toMatchObject({ + Bucket: 'test-bucket', + Key: 'empty.txt', + ContentType: 'text/plain', + ContentLength: 0 + }); + expect(Buffer.isBuffer(cmd.input.Body)).toBe(true); + expect((cmd.input.Body as Buffer).length).toBe(0); + }); }); // --------------------------------------------------------------------------- @@ -385,7 +418,7 @@ describe('S3StorageProvider.deleteObjects', () => { it('returns empty failed list on full success', async () => { send.mockResolvedValue({ Errors: [] }); - const result = await provider.deleteObjects(['a.txt', 'b.txt']); + const result = await provider.deleteObjects(['a.txt', 'b.txt'], true); expect(result.failed).toHaveLength(0); }); @@ -393,7 +426,7 @@ describe('S3StorageProvider.deleteObjects', () => { send.mockResolvedValue({ Errors: [{ Key: 'b.txt', Code: 'AccessDenied', Message: 'No permission' }] }); - const result = await provider.deleteObjects(['a.txt', 'b.txt']); + const result = await provider.deleteObjects(['a.txt', 'b.txt'], true); expect(result.failed).toEqual([ { key: 'b.txt', code: 'AccessDenied', message: 'No permission' } ]); @@ -403,19 +436,19 @@ describe('S3StorageProvider.deleteObjects', () => { send.mockResolvedValue({ Errors: [{ Code: 'InternalError', Message: 'oops' }] }); - const result = await provider.deleteObjects(['x.txt']); + const result = await provider.deleteObjects(['x.txt'], true); expect(result.failed[0].key).toBe(''); }); it('handles undefined Errors from S3', async () => { send.mockResolvedValue({}); - const result = await provider.deleteObjects(['a.txt']); + const result = await provider.deleteObjects(['a.txt'], true); expect(result.failed).toHaveLength(0); }); it('sends correct Delete structure to S3', async () => { send.mockResolvedValue({ Errors: [] }); - await provider.deleteObjects(['x.txt', 'y.txt']); + await provider.deleteObjects(['x.txt', 'y.txt'], true); const sentCommand = send.mock.calls[0][0]; expect(sentCommand.input.Delete.Objects).toEqual([{ Key: 'x.txt' }, { Key: 'y.txt' }]); expect(sentCommand.input.Delete.Quiet).toBe(true); @@ -423,69 +456,157 @@ describe('S3StorageProvider.deleteObjects', () => { }); // --------------------------------------------------------------------------- -// listAllKeys +// copyObject // --------------------------------------------------------------------------- -describe('S3StorageProvider.listAllKeys', () => { +describe('S3StorageProvider.copyObject', () => { let provider: S3StorageProvider; let send: ReturnType; beforeEach(() => { ({ provider, send } = makeProvider()); + mockUploadDone.mockResolvedValue(undefined); + MockUpload.mockClear(); }); - it('returns all keys in a single page', async () => { - send.mockResolvedValue({ - Contents: [{ Key: 'a.txt' }, { Key: 'b.txt' }], - IsTruncated: false + it('uses CopyObjectCommand for files under 5 GB', async () => { + // HeadObject returns ContentLength = 1000 + send.mockResolvedValueOnce({ ContentLength: 1000 }); + // CopyObject succeeds + send.mockResolvedValueOnce({}); + + await provider.copyObject('src/file.txt', 'dst/file.txt'); + + expect(send).toHaveBeenCalledTimes(2); + const copyCmd = send.mock.calls[1][0]; + expect(copyCmd).toBeInstanceOf(CopyObjectCommand); + expect(copyCmd.input).toMatchObject({ + Bucket: 'test-bucket', + CopySource: '/test-bucket/src%2Ffile.txt', + Key: 'dst/file.txt' }); + expect(MockUpload).not.toHaveBeenCalled(); + }); + + it('skips the HEAD when the caller knows the size is under 5 GB', async () => { + send.mockResolvedValueOnce({}); + + await provider.copyObject('src/file.txt', 'dst/file.txt', { size: 1000 }); - const keys = await provider.listAllKeys(''); - expect(keys).toEqual(['a.txt', 'b.txt']); expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toBeInstanceOf(CopyObjectCommand); }); - it('paginates across multiple pages', async () => { - send - .mockResolvedValueOnce({ - Contents: [{ Key: 'a.txt' }, { Key: 'b.txt' }], - IsTruncated: true, - NextContinuationToken: 'token-1' - }) - .mockResolvedValueOnce({ - Contents: [{ Key: 'c.txt' }], - IsTruncated: false - }); - - const keys = await provider.listAllKeys('prefix/'); - expect(keys).toEqual(['a.txt', 'b.txt', 'c.txt']); - expect(send).toHaveBeenCalledTimes(2); + it('names the source when it vanished before a copy without HEAD', async () => { + send.mockRejectedValueOnce(makeS3Error('NoSuchKey', 404)); - // Second call must pass the continuation token - const secondCall = send.mock.calls[1][0]; - expect(secondCall.input.ContinuationToken).toBe('token-1'); + await expect( + provider.copyObject('src/file.txt', 'dst/file.txt', { size: 1000 }) + ).rejects.toMatchObject({ status: 404, body: { message: 'Object "src/file.txt" not found' } }); }); - it('returns empty array when no contents', async () => { - send.mockResolvedValue({ IsTruncated: false }); - const keys = await provider.listAllKeys('empty/'); - expect(keys).toEqual([]); + it('still reads the source for the ETag pin when a known size is over 5 GB', async () => { + const size = 5 * 1024 * 1024 * 1024 + 1; + send.mockResolvedValueOnce({ ContentLength: size, ETag: '"abc"' }); + send.mockResolvedValue({ UploadId: 'id', CopyPartResult: { ETag: 'part' } }); + + await provider.copyObject('src/large.parquet', 'dst/large.parquet', { size }); + + expect(send.mock.calls[0][0]).toBeInstanceOf(HeadObjectCommand); + expect(send.mock.calls[2][0].input).toMatchObject({ CopySourceIfMatch: '"abc"' }); }); - it('skips Contents entries with no Key', async () => { - send.mockResolvedValue({ - Contents: [{ Key: 'valid.txt' }, {}], - IsTruncated: false + it('uses server-side multipart copy for files over 5 GB', async () => { + const fiveGB = 5 * 1024 * 1024 * 1024; + const largeSize = fiveGB + 1; + // PART_SIZE = 256 MiB → 21 parts for (5GB + 1) + const numParts = 21; + const partSize = 256 * 1024 * 1024; + + // HeadObject + send.mockResolvedValueOnce({ + ContentLength: largeSize, + ContentType: 'application/octet-stream' + }); + // CreateMultipartUpload + send.mockResolvedValueOnce({ UploadId: 'test-upload-id' }); + // UploadPartCopy × numParts + for (let i = 0; i < numParts; i++) { + send.mockResolvedValueOnce({ CopyPartResult: { ETag: `etag-${i + 1}` } }); + } + // CompleteMultipartUpload + send.mockResolvedValueOnce({}); + + await provider.copyObject('src/large.parquet', 'dst/large.parquet'); + + // Total calls: 1 Head + 1 Create + numParts UploadPartCopy + 1 Complete = 24 + expect(send).toHaveBeenCalledTimes(1 + 1 + numParts + 1); + + const createCmd = send.mock.calls[1][0]; + expect(createCmd.constructor.name).toBe('CreateMultipartUploadCommand'); + expect(createCmd.input).toMatchObject({ + Bucket: 'test-bucket', + Key: 'dst/large.parquet', + ContentType: 'application/octet-stream' + }); + + for (let i = 0; i < numParts; i++) { + const partCmd = send.mock.calls[2 + i][0]; + expect(partCmd.constructor.name).toBe('UploadPartCopyCommand'); + const startByte = i * partSize; + const endByte = Math.min(startByte + partSize - 1, largeSize - 1); + expect(partCmd.input).toMatchObject({ + Bucket: 'test-bucket', + Key: 'dst/large.parquet', + UploadId: 'test-upload-id', + PartNumber: i + 1, + CopySource: '/test-bucket/src%2Flarge.parquet', + CopySourceRange: `bytes=${startByte}-${endByte}` + }); + } + + const completeCmd = send.mock.calls[1 + 1 + numParts][0]; + expect(completeCmd.constructor.name).toBe('CompleteMultipartUploadCommand'); + expect(completeCmd.input).toMatchObject({ + Bucket: 'test-bucket', + Key: 'dst/large.parquet', + UploadId: 'test-upload-id', + MultipartUpload: { + Parts: Array.from({ length: numParts }, (_, i) => ({ + PartNumber: i + 1, + ETag: `etag-${i + 1}` + })) + } }); - const keys = await provider.listAllKeys(''); - expect(keys).toEqual(['valid.txt']); }); - it('does not send delimiter (recursive listing)', async () => { - send.mockResolvedValue({ IsTruncated: false }); - await provider.listAllKeys('p/'); - const sentCommand = send.mock.calls[0][0]; - expect(sentCommand.input.Delimiter).toBeUndefined(); + it('reports progress during server-side multipart copy', async () => { + const size = 6 * 1024 * 1024 * 1024; // exactly 24 parts of 256 MiB + const partSize = 256 * 1024 * 1024; + const numParts = Math.ceil(size / partSize); // 24 + + const onProgress = vi.fn(); + + // HeadObject + send.mockResolvedValueOnce({ ContentLength: size, ContentType: 'video/mp4' }); + // CreateMultipartUpload + send.mockResolvedValueOnce({ UploadId: 'upload-2' }); + // UploadPartCopy × numParts + for (let i = 0; i < numParts; i++) { + send.mockResolvedValueOnce({ CopyPartResult: { ETag: `e${i}` } }); + } + // CompleteMultipartUpload + send.mockResolvedValueOnce({}); + + await provider.copyObject('src/video.mp4', 'dst/video.mp4', { onProgress }); + + expect(onProgress).toHaveBeenCalledTimes(numParts); + // Each call reports cumulative progress + for (let i = 0; i < numParts; i++) { + expect(onProgress).toHaveBeenNthCalledWith(i + 1, (i + 1) * partSize, size); + } + // Last call reports total size + expect(onProgress).toHaveBeenLastCalledWith(size, size); }); }); @@ -527,3 +648,249 @@ describe('S3StorageProvider constructor', () => { ); }); }); + +// --------------------------------------------------------------------------- +// listContainers +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.listContainers', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + }); + + it('returns bucket names', async () => { + send.mockResolvedValue({ Buckets: [{ Name: 'a' }, { Name: 'b' }] }); + expect(await provider.listContainers()).toEqual(['a', 'b']); + }); + + it('filters out buckets with no name', async () => { + send.mockResolvedValue({ Buckets: [{ Name: 'a' }, { Name: undefined }, { Name: '' }] }); + expect(await provider.listContainers()).toEqual(['a']); + }); + + it('handles null Buckets in response', async () => { + send.mockResolvedValue({ Buckets: null }); + expect(await provider.listContainers()).toEqual([]); + }); + + it('maps AccessDenied S3 error to HTTP 403', async () => { + send.mockRejectedValue(makeS3Error('AccessDenied', 403)); + await expect(provider.listContainers()).rejects.toMatchObject({ status: 403 }); + }); + + it('re-throws non-S3 errors', async () => { + const err = new Error('network'); + send.mockRejectedValue(err); + await expect(provider.listContainers()).rejects.toThrow('network'); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling — S3ServiceException → HTTP error mapping +// --------------------------------------------------------------------------- + +describe('S3StorageProvider error handling', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + }); + + it('listObjects: maps AccessDenied to HTTP 403', async () => { + send.mockRejectedValue(makeS3Error('AccessDenied', 403)); + await expect(provider.listObjects('', 10)).rejects.toMatchObject({ status: 403 }); + }); + + it('listObjects: maps NoSuchBucket to HTTP 404', async () => { + send.mockRejectedValue(makeS3Error('NoSuchBucket', 404)); + await expect(provider.listObjects('', 10)).rejects.toMatchObject({ status: 404 }); + }); + + it('listObjects: re-throws non-S3 errors', async () => { + send.mockRejectedValue(new Error('timeout')); + await expect(provider.listObjects('', 10)).rejects.toThrow('timeout'); + }); + + it('getObject: maps NoSuchKey to HTTP 404', async () => { + send.mockRejectedValue(makeS3Error('NoSuchKey', 404)); + await expect(provider.getObject('k')).rejects.toMatchObject({ status: 404 }); + }); + + it('getObject: maps AccessDenied to HTTP 403', async () => { + send.mockRejectedValue(makeS3Error('AccessDenied', 403)); + await expect(provider.getObject('k')).rejects.toMatchObject({ status: 403 }); + }); + + it('getObject: re-throws non-S3 errors', async () => { + send.mockRejectedValue(new Error('gone')); + await expect(provider.getObject('k')).rejects.toThrow('gone'); + }); + + it('getObjectRange: maps S3 error to HTTP error', async () => { + send.mockRejectedValue(makeS3Error('NoSuchKey', 404)); + await expect(provider.getObjectRange('k', 0, 100)).rejects.toMatchObject({ status: 404 }); + }); + + it('getObjectRange: re-throws non-S3 errors', async () => { + send.mockRejectedValue(new Error('network')); + await expect(provider.getObjectRange('k', 0, 100)).rejects.toThrow('network'); + }); + + it('getMetadata: maps NoSuchKey to HTTP 404', async () => { + send.mockRejectedValue(makeS3Error('NoSuchKey', 404)); + await expect(provider.getMetadata('k')).rejects.toMatchObject({ status: 404 }); + }); + + it('getMetadata: re-throws non-S3 errors', async () => { + send.mockRejectedValue(new Error('gone')); + await expect(provider.getMetadata('k')).rejects.toThrow('gone'); + }); + + it('putObject: maps AccessDenied to HTTP 403', async () => { + mockUploadDone.mockRejectedValue(makeS3Error('AccessDenied', 403)); + MockUpload.mockImplementationOnce(function (this: { done: typeof mockUploadDone }) { + this.done = mockUploadDone; + }); + await expect(provider.putObject('k', Buffer.from('x'), 'text/plain')).rejects.toMatchObject({ + status: 403 + }); + }); + + it('putObject: re-throws non-S3 errors', async () => { + mockUploadDone.mockRejectedValue(new Error('disk full')); + MockUpload.mockImplementationOnce(function (this: { done: typeof mockUploadDone }) { + this.done = mockUploadDone; + }); + await expect(provider.putObject('k', Buffer.from('x'), 'text/plain')).rejects.toThrow( + 'disk full' + ); + }); + + it('deleteObjects: maps AccessDenied to HTTP 403', async () => { + send.mockRejectedValue(makeS3Error('AccessDenied', 403)); + await expect(provider.deleteObjects(['file.txt'], true)).rejects.toMatchObject({ status: 403 }); + }); + + it('deleteObjects: re-throws non-S3 errors', async () => { + send.mockRejectedValue(new Error('network')); + await expect(provider.deleteObjects(['file.txt'], true)).rejects.toThrow('network'); + }); +}); + +// --------------------------------------------------------------------------- +// deleteObjects — directory expansion +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.deleteObjects directory expansion', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + }); + + it('expands directory prefixes and deletes all contained keys', async () => { + // First call: ListObjectsV2 for dir/ + send.mockResolvedValueOnce({ + Contents: [{ Key: 'dir/a.txt' }, { Key: 'dir/b.txt' }], + IsTruncated: false + }); + // Second call: DeleteObjects + send.mockResolvedValueOnce({ Errors: [] }); + + const result = await provider.deleteObjects(['file.txt', 'dir/'], true); + + const deleteCall = send.mock.calls[1][0]; + expect(deleteCall.input.Delete.Objects).toEqual([ + { Key: 'file.txt' }, + { Key: 'dir/a.txt' }, + { Key: 'dir/b.txt' } + ]); + expect(result).toEqual({ failed: [] }); + }); + + it('uses directory key itself when no children found', async () => { + send.mockResolvedValueOnce({ IsTruncated: false }); // nothing listed under empty/ + send.mockResolvedValueOnce({ Errors: [] }); // deleteObjects + + await provider.deleteObjects(['empty/'], true); + + const deleteCall = send.mock.calls[1][0]; + expect(deleteCall.input.Delete.Objects).toEqual([{ Key: 'empty/' }]); + }); + + it('returns empty result for no keys', async () => { + const result = await provider.deleteObjects([], true); + expect(result).toEqual({ failed: [] }); + expect(send).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// putObjectIfMatch +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.putObjectIfMatch', () => { + it('sends a single PutObject with If-Match and returns the new ETag', async () => { + const { provider, send } = makeProvider(); + send.mockResolvedValue({ ETag: '"new"' }); + const body = Buffer.from('hello'); + + await expect(provider.putObjectIfMatch('file.txt', body, 'text/plain', '"old"')).resolves.toBe( + '"new"' + ); + + expect(send).toHaveBeenCalledTimes(1); + const cmd = send.mock.calls[0][0] as PutObjectCommand; + expect(cmd).toBeInstanceOf(PutObjectCommand); + expect(cmd.input).toMatchObject({ + Bucket: 'test-bucket', + Key: 'file.txt', + Body: body, + ContentType: 'text/plain', + ContentLength: 5, + IfMatch: '"old"' + }); + }); + + it.each([ + ['PreconditionFailed', 412], + ['ConditionalRequestConflict', 409], + ['NoSuchKey', 404] + ])('maps %s (%i) to an HTTP 409 conflict', async (name, status) => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error(name, status)); + + await expect( + provider.putObjectIfMatch('file.txt', Buffer.from('x'), 'text/plain', '"old"') + ).rejects.toMatchObject({ status: 409 }); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('retries without If-Match when the provider does not implement it', async () => { + const { provider, send } = makeProvider(); + send + .mockRejectedValueOnce(makeS3Error('NotImplemented', 501)) + .mockResolvedValueOnce({ ETag: '"new"' }); + + await expect( + provider.putObjectIfMatch('file.txt', Buffer.from('x'), 'text/plain', '"old"') + ).resolves.toBe('"new"'); + + expect(send).toHaveBeenCalledTimes(2); + expect((send.mock.calls[1][0] as PutObjectCommand).input.IfMatch).toBeUndefined(); + }); + + it('maps other S3 errors via the shared mapping', async () => { + const { provider, send } = makeProvider(); + send.mockRejectedValue(makeS3Error('AccessDenied', 403)); + + await expect( + provider.putObjectIfMatch('file.txt', Buffer.from('x'), 'text/plain', '"old"') + ).rejects.toMatchObject({ status: 403 }); + }); +}); diff --git a/src/lib/server/storage/s3-provider.ts b/src/lib/server/storage/s3-provider.ts index 56dadd73..631f2eb4 100644 --- a/src/lib/server/storage/s3-provider.ts +++ b/src/lib/server/storage/s3-provider.ts @@ -1,21 +1,119 @@ import { S3Client, S3ServiceException, + ListBucketsCommand, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, + DeleteObjectCommand, DeleteObjectsCommand, + PutObjectCommand, + GetBucketVersioningCommand, + GetBucketLifecycleConfigurationCommand, + GetBucketTaggingCommand, + GetBucketAclCommand, + CopyObjectCommand, + CreateMultipartUploadCommand, + UploadPartCopyCommand, + CompleteMultipartUploadCommand, + AbortMultipartUploadCommand, type ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; -import type { StorageProvider, ObjectDownload, DeleteObjectsResult } from './provider.js'; +import { error } from '@sveltejs/kit'; +import type { + StorageProvider, + ObjectDownload, + DeleteObjectsResult, + SearchOptions, + SearchResult, + ProgressiveListOptions, + CopyObjectOptions +} from './provider.js'; import type { S3Config } from './types.js'; -import type { StoragePage, StorageObject, StorageMetadata } from '$lib/storage/types.js'; +import type { + StoragePage, + StorageObject, + StorageMetadata, + SearchResultItem +} from '$lib/storage/types.js'; +import type { LifecycleRule, BucketAcl, BucketVersioning } from '$lib/storage/details-types.js'; import { logger } from '$lib/server/logging'; import { createS3Client } from './s3-client.js'; +import { mapS3ErrorToHttp } from './s3-errors.js'; const log = logger.child({ module: 's3-provider' }); +/** + * Listings request `EncodingType: 'url'` so that keys with characters XML cannot carry (most + * control characters) or that XML parsing normalises (`\r`) arrive intact. S3 then encodes + * `Key`, `Prefix`, `CommonPrefixes[].Prefix`, `StartAfter` and `Delimiter` in the response, using + * form encoding: AWS sends a space as `+` and a literal `+` as `%2B`, so `+` is decoded as a space + * before percent-decoding, as botocore (the AWS SDK for Python) does. Continuation tokens are not + * encoded. + */ +const LIST_ENCODING_TYPE = 'url'; + +/** Deletes of keys DeleteObjects cannot name run with this many requests in flight. */ +const SINGLE_DELETE_CONCURRENCY = 4; + +/** + * S3 DeleteObjects accepts at most 1000 keys per request. + * MinIO/Ionos return MalformedXML when exceeding this limit. + */ +const DELETE_OBJECTS_MAX_KEYS = 1000; + +/** A key DeleteObjects or DeleteObject could not delete. */ +type DeleteFailure = DeleteObjectsResult['failed'][number]; + +/** An object from a recursive listing. */ +type ListedObject = { key: string; size: number; lastModified?: Date }; + +/** + * The decoder for a listing response's key fields. Only a response that echoes + * `EncodingType: 'url'` is decoded (as botocore does): a store that ignores the parameter returns + * raw keys, where `+` and `%` are literal. + */ +function listingDecoder(output: { EncodingType?: string }): (value: string) => string { + return output.EncodingType === LIST_ENCODING_TYPE ? decodeListingValue : (value) => value; +} + +/** Decodes a url-encoded listing field. A malformed value is returned unchanged. */ +function decodeListingValue(value: string): string { + try { + return decodeURIComponent(value.replaceAll('+', ' ')); + } catch { + log.warn({ encoded_value: value }, 'S3 listing returned a malformed url-encoded value'); + return value; + } +} + +/** + * Whether XML 1.0 can carry `key`. DeleteObjects names keys in an XML body, and S3 rejects the + * whole request as MalformedXML when a key holds a C0 control character other than tab, newline + * or carriage return (the SDK escapes those), a non-character or an unpaired surrogate. + */ +function isXmlSafeKey(key: string): boolean { + if (!key.isWellFormed()) return false; + return [...key].every((char) => { + const codePoint = char.codePointAt(0)!; + if (codePoint < 0x20) return codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d; + return codePoint !== 0xfffe && codePoint !== 0xffff; + }); +} + +/** Runs `fn` and maps any S3ServiceException to an HTTP error via `mapS3ErrorToHttp`. */ +async function withS3Errors( + fn: () => Promise, + context: Parameters[1] +): Promise { + try { + return await fn(); + } catch (err) { + return mapS3ErrorToHttp(err, context); + } +} + export class S3StorageProvider implements StorageProvider { private readonly client: S3Client; private readonly bucket: string; @@ -25,36 +123,55 @@ export class S3StorageProvider implements StorageProvider { this.client = client ?? createS3Client(config); } + async listContainers(): Promise { + log.trace({ bucket: this.bucket }, 'S3 ListBuckets'); + return withS3Errors( + async () => { + const output = await this.client.send(new ListBucketsCommand({})); + const buckets = (output.Buckets ?? []).map((b) => b.Name ?? '').filter(Boolean); + log.debug({ bucket_count: buckets.length }, 'listed buckets'); + return buckets; + }, + { operation: 'listBuckets' } + ); + } + async listObjects( prefix: string, pageSize: number, continuationToken?: string | null ): Promise { log.trace({ bucket: this.bucket, prefix, page_size: pageSize }, 'S3 ListObjectsV2'); - - const output: ListObjectsV2CommandOutput = await this.client.send( - new ListObjectsV2Command({ - Bucket: this.bucket, - Prefix: prefix || undefined, - Delimiter: '/', - MaxKeys: pageSize, - ContinuationToken: continuationToken ?? undefined - }) + const output = await withS3Errors( + () => + this.client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: prefix || undefined, + Delimiter: '/', + MaxKeys: pageSize, + ContinuationToken: continuationToken ?? undefined, + EncodingType: LIST_ENCODING_TYPE + }) + ), + { bucket: this.bucket, operation: 'listObjects' } ); + const decode = listingDecoder(output); const objects: StorageObject[] = [ ...(output.CommonPrefixes ?? []).map((cp) => ({ - key: cp.Prefix ?? '', + key: decode(cp.Prefix ?? ''), size: 0, lastModified: new Date(0), isDirectory: true, contentType: undefined })), ...(output.Contents ?? []) + .map((obj) => ({ ...obj, Key: decode(obj.Key ?? '') })) // Filter out the prefix itself (S3 sometimes echoes it back) .filter((obj) => obj.Key !== prefix) .map((obj) => ({ - key: obj.Key ?? '', + key: obj.Key, size: obj.Size ?? 0, lastModified: obj.LastModified ?? new Date(0), isDirectory: false, @@ -74,39 +191,61 @@ export class S3StorageProvider implements StorageProvider { async getObject(key: string): Promise { log.trace({ bucket: this.bucket, key }, 'S3 GetObject'); - const output = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); - if (!output.Body) { - throw new Error(`Object ${key} has no body`); - } - return { - stream: output.Body.transformToWebStream(), - contentType: output.ContentType, - contentLength: output.ContentLength, - etag: output.ETag - }; + return withS3Errors( + async () => { + const output = await this.client.send( + new GetObjectCommand({ Bucket: this.bucket, Key: key }) + ); + if (!output.Body) { + throw new Error(`Object ${key} has no body`); + } + return { + stream: output.Body.transformToWebStream(), + contentType: output.ContentType, + contentLength: output.ContentLength, + etag: output.ETag + }; + }, + { bucket: this.bucket, key, operation: 'getObject' } + ); } async getObjectRange(key: string, start: number, end: number): Promise { log.trace({ bucket: this.bucket, key, start, end }, 'S3 GetObject (range)'); - const output = await this.client.send( - new GetObjectCommand({ Bucket: this.bucket, Key: key, Range: `bytes=${start}-${end}` }) + return withS3Errors( + async () => { + const output = await this.client.send( + new GetObjectCommand({ Bucket: this.bucket, Key: key, Range: `bytes=${start}-${end}` }) + ); + if (!output.Body) { + throw new Error(`Object ${key} has no body`); + } + return output.Body.transformToWebStream(); + }, + { bucket: this.bucket, key, operation: 'getObjectRange' } ); - if (!output.Body) { - throw new Error(`Object ${key} has no body`); - } - return output.Body.transformToWebStream(); } async getMetadata(key: string): Promise { log.trace({ bucket: this.bucket, key }, 'S3 HeadObject'); - const output = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })); - return { - size: output.ContentLength ?? 0, - lastModified: output.LastModified ?? new Date(0), - contentType: output.ContentType, - etag: output.ETag, - customMetadata: output.Metadata - }; + return withS3Errors( + async () => { + const output = await this.client.send( + new HeadObjectCommand({ Bucket: this.bucket, Key: key }) + ); + return { + size: output.ContentLength ?? 0, + lastModified: output.LastModified ?? new Date(0), + contentType: output.ContentType, + etag: output.ETag, + customMetadata: output.Metadata, + versionId: output.VersionId, + storageClass: output.StorageClass, + isDeleteMarker: output.DeleteMarker ?? false + }; + }, + { bucket: this.bucket, key, operation: 'getMetadata' } + ); } async exists(key: string): Promise { @@ -128,12 +267,33 @@ export class S3StorageProvider implements StorageProvider { key: string, body: ReadableStream | Buffer, contentType: string, - contentLength?: number + contentLength?: number, + onProgress?: (loaded: number, total: number) => void ): Promise { log.trace( { bucket: this.bucket, key, content_type: contentType, content_length: contentLength }, 'S3 Upload' ); + + // Empty files cannot use multipart upload (S3 rejects empty parts). + // Use a simple PutObject request instead. + if (contentLength === 0) { + await withS3Errors( + () => + this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: Buffer.alloc(0), + ContentType: contentType, + ContentLength: 0 + }) + ), + { bucket: this.bucket, key, operation: 'putObject' } + ); + return; + } + const upload = new Upload({ client: this.client, queueSize: 4, @@ -146,40 +306,106 @@ export class S3StorageProvider implements StorageProvider { ...(contentLength !== undefined ? { ContentLength: contentLength } : {}) } }); - await upload.done(); + if (onProgress && contentLength) { + upload.on('httpUploadProgress', (progress) => { + onProgress(progress.loaded ?? 0, contentLength); + }); + } + await withS3Errors(() => upload.done(), { bucket: this.bucket, key, operation: 'putObject' }); } - async deleteObjects(keys: string[]): Promise { - log.trace({ bucket: this.bucket, key_count: keys.length }, 'S3 DeleteObjects'); - - // S3 DeleteObjects has a limit of 1000 keys per request. - // MinIO/Ionos return MalformedXML when exceeding this limit. - const MAX_KEYS = 1000; - const allFailed: Array<{ key: string; code?: string; message?: string }> = []; - - for (let i = 0; i < keys.length; i += MAX_KEYS) { - const chunk = keys.slice(i, i + MAX_KEYS); - log.trace( - { bucket: this.bucket, chunk_offset: i, chunk_size: chunk.length }, - 'S3 DeleteObjects chunk' - ); - const output = await this.client.send( - new DeleteObjectsCommand({ + async putObjectIfMatch( + key: string, + body: Buffer, + contentType: string, + etag: string + ): Promise { + log.trace( + { bucket: this.bucket, key, content_type: contentType, content_length: body.length }, + 'S3 PutObject (If-Match)' + ); + const put = (ifMatch?: string) => + this.client.send( + new PutObjectCommand({ Bucket: this.bucket, - Delete: { - Objects: chunk.map((key) => ({ Key: key })), - Quiet: true - } + Key: key, + Body: body, + ContentType: contentType, + ContentLength: body.length, + ...(ifMatch !== undefined ? { IfMatch: ifMatch } : {}) }) ); - const failed = (output.Errors ?? []).map((e) => ({ - key: e.Key ?? '', - code: e.Code, - message: e.Message - })); - allFailed.push(...failed); + + try { + return (await put(etag)).ETag; + } catch (err) { + if (!(err instanceof S3ServiceException)) throw err; + const status = err.$metadata?.httpStatusCode; + // 412: ETag no longer matches. 409: concurrent conditional write. + // 404: the object was deleted since it was loaded. + if (status === 412 || status === 409 || status === 404) { + log.info( + { bucket: this.bucket, key, error_code: err.name, status_code: status }, + 'conditional PutObject rejected: object changed since it was loaded' + ); + throw error(409, 'The object was modified since it was loaded'); + } + // Providers without conditional-write support reject the header. The + // caller's HEAD comparison is then the only (racy) guard. + if (status === 501) { + log.warn( + { bucket: this.bucket, key, error_code: err.name }, + 'S3 provider does not support If-Match on PutObject; retrying unconditionally' + ); + return withS3Errors(async () => (await put()).ETag, { + bucket: this.bucket, + key, + operation: 'putObject' + }); + } + return mapS3ErrorToHttp(err, { bucket: this.bucket, key, operation: 'putObject' }); + } + } + + async deleteObjects(keys: string[], expandDirectories: boolean): Promise { + log.trace({ bucket: this.bucket, key_count: keys.length }, 'S3 DeleteObjects'); + + if (keys.length === 0) { + return { failed: [] }; } + const allFailed: DeleteFailure[] = []; + // Directory contents are deleted page by page while they are listed, so at most + // about two chunks of keys are held at once, however large the directory. + const pending: string[] = []; + const flush = async (all: boolean) => { + while (pending.length >= DELETE_OBJECTS_MAX_KEYS || (all && pending.length > 0)) { + allFailed.push(...(await this.deleteChunk(pending.splice(0, DELETE_OBJECTS_MAX_KEYS)))); + } + }; + + await withS3Errors( + async () => { + for (const key of keys) { + if (!(expandDirectories && key.endsWith('/'))) { + pending.push(key); + await flush(false); + continue; + } + let found = false; + for await (const batch of this.listPages(key)) { + found ||= batch.length > 0; + pending.push(...batch.map((obj) => obj.key)); + await flush(false); + } + // Nothing is listed under the prefix: delete the key itself. + if (!found) pending.push(key); + } + await flush(true); + }, + { bucket: this.bucket, operation: 'deleteObjects' } + ); + if (allFailed.length > 0) { log.warn( { bucket: this.bucket, failed_count: allFailed.length }, @@ -189,29 +415,427 @@ export class S3StorageProvider implements StorageProvider { return { failed: allFailed }; } - async listAllKeys(prefix: string): Promise { - log.trace({ bucket: this.bucket, prefix }, 'S3 ListObjectsV2 (recursive)'); - const keys: string[] = []; - let continuationToken: string | undefined; + /** Deletes up to {@link DELETE_OBJECTS_MAX_KEYS} keys and returns the ones that failed. */ + private async deleteChunk(chunk: string[]): Promise { + // Keys XML cannot carry are deleted one by one, so they neither fail nor block a batch. + const failed = await this.deleteSingly(chunk.filter((key) => !isXmlSafeKey(key))); + const batchKeys = chunk.filter(isXmlSafeKey); + if (batchKeys.length === 0) return failed; + log.trace({ bucket: this.bucket, chunk_size: batchKeys.length }, 'S3 DeleteObjects chunk'); + const output = await this.client.send( + new DeleteObjectsCommand({ + Bucket: this.bucket, + Delete: { + Objects: batchKeys.map((key) => ({ Key: key })), + Quiet: true + } + }) + ); + for (const e of output.Errors ?? []) { + failed.push({ key: e.Key ?? '', code: e.Code, message: e.Message }); + } + return failed; + } + + /** Deletes keys with individual DeleteObject requests, a few at a time. */ + private async deleteSingly(keys: string[]): Promise { + const failed: DeleteFailure[] = []; + const pending = [...keys]; + const worker = async () => { + for (let key = pending.shift(); key !== undefined; key = pending.shift()) { + log.debug({ bucket: this.bucket, key }, 'S3 DeleteObject (key not representable in XML)'); + try { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); + } catch (err) { + if (!(err instanceof S3ServiceException)) throw err; + failed.push({ key, code: err.name, message: err.message }); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(SINGLE_DELETE_CONCURRENCY, keys.length) }, worker) + ); + return failed; + } + + /** + * Runs a bucket-info request. Errors whose code is in `emptyCodes` mean the + * setting is not configured and yield `empty`; any other failure is logged + * and yields `null`, so callers can show the section as unavailable rather + * than as a misleading default. + */ + private async bucketInfo( + operation: string, + fn: () => Promise, + empty?: { codes: string[]; value: T } + ): Promise { + try { + return await fn(); + } catch (err) { + const isS3 = err instanceof S3ServiceException; + if (isS3 && empty?.codes.includes(err.name)) return empty.value; + log.warn( + { + bucket: this.bucket, + operation, + error_code: isS3 ? err.name : undefined, + status_code: isS3 ? err.$metadata?.httpStatusCode : undefined, + err: isS3 ? undefined : err + }, + 'bucket info request failed' + ); + return null; + } + } + + async getBucketVersioning(): Promise { + return this.bucketInfo('getBucketVersioning', async () => { + const output = await this.client.send( + new GetBucketVersioningCommand({ Bucket: this.bucket }) + ); + if (output.Status === 'Enabled') return 'Enabled'; + if (output.Status === 'Suspended') return 'Suspended'; + return 'Disabled'; + }); + } + + async getBucketLifecycleRules(): Promise { + return this.bucketInfo( + 'getBucketLifecycleRules', + async () => { + const output = await this.client.send( + new GetBucketLifecycleConfigurationCommand({ Bucket: this.bucket }) + ); + return (output.Rules ?? []).map((rule) => { + const expiration = rule.Expiration; + const noncurrentExpiration = rule.NoncurrentVersionExpiration; + const abortMpu = rule.AbortIncompleteMultipartUpload; + + return { + id: rule.ID ?? '', + status: rule.Status === 'Enabled' ? 'Enabled' : 'Disabled', + filter: (rule.Filter as Record) ?? {}, + transitions: (rule.Transitions ?? []).map((t) => ({ + days: t.Days ?? 0, + storageClass: t.StorageClass ?? '' + })), + expirations: expiration + ? [ + { + days: expiration.Days, + date: expiration.Date?.toISOString(), + expiredObjectDeleteMarker: expiration.ExpiredObjectDeleteMarker + } + ] + : [], + noncurrentVersionTransitions: (rule.NoncurrentVersionTransitions ?? []).map((t) => ({ + noncurrentDays: t.NoncurrentDays ?? 0, + storageClass: t.StorageClass ?? '' + })), + noncurrentVersionExpirations: noncurrentExpiration + ? [{ noncurrentDays: noncurrentExpiration.NoncurrentDays ?? 0 }] + : [], + abortIncompleteMultipartUploads: abortMpu + ? [{ daysAfterInitiation: abortMpu.DaysAfterInitiation ?? 0 }] + : [] + }; + }); + }, + { codes: ['NoSuchLifecycleConfiguration'], value: [] } + ); + } + + async getBucketAcl(): Promise { + return this.bucketInfo('getBucketAcl', async () => { + const output = await this.client.send(new GetBucketAclCommand({ Bucket: this.bucket })); + const owner = [output.Owner?.DisplayName, output.Owner?.ID].filter(Boolean).join(' / '); + const grants = (output.Grants ?? []).map((g) => ({ + grantee: + g.Grantee?.DisplayName ?? + g.Grantee?.EmailAddress ?? + g.Grantee?.ID ?? + g.Grantee?.URI ?? + g.Grantee?.Type ?? + 'Unknown', + permission: g.Permission ?? 'Unknown' + })); + return { owner: owner || 'Unknown', grants }; + }); + } + + async getBucketTags(): Promise | null> { + return this.bucketInfo( + 'getBucketTags', + async () => { + const output = await this.client.send(new GetBucketTaggingCommand({ Bucket: this.bucket })); + const tags: Record = {}; + for (const tag of output.TagSet ?? []) { + if (tag.Key) tags[tag.Key] = tag.Value ?? ''; + } + return tags; + }, + { codes: ['NoSuchTagSet'], value: {} } + ); + } + + /** + * Yields every object under `prefix` (no delimiter), one listing page at a time. + * Pages are fetched strictly one after another: each continuation token is only + * known once the previous page has arrived. + */ + private async *listPages(prefix: string, signal?: AbortSignal): AsyncGenerator { + let continuationToken: string | undefined; do { const output: ListObjectsV2CommandOutput = await this.client.send( new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, - ContinuationToken: continuationToken - }) + ContinuationToken: continuationToken, + EncodingType: LIST_ENCODING_TYPE + }), + { abortSignal: signal } + ); + const decode = listingDecoder(output); + yield (output.Contents ?? []).flatMap((obj) => + obj.Key + ? [{ key: decode(obj.Key), size: obj.Size ?? 0, lastModified: obj.LastModified }] + : [] ); - for (const obj of output.Contents ?? []) { - if (obj.Key) keys.push(obj.Key); - } continuationToken = output.IsTruncated ? output.NextContinuationToken : undefined; } while (continuationToken); + } + + async listAllKeysProgressively( + prefix: string, + onBatch: (keys: ListedObject[]) => void | boolean, + options?: ProgressiveListOptions + ): Promise { + log.trace({ bucket: this.bucket, prefix }, 'S3 ListObjectsV2 (progressive)'); + for await (const batch of this.listPages(prefix, options?.signal)) { + if (batch.length > 0 && onBatch(batch) === false) break; + } + log.trace({ bucket: this.bucket, prefix }, 'progressive listing complete'); + } + + async search(query: string, options?: SearchOptions): Promise { + const signal = options?.signal; + const prefix = options?.prefix ?? ''; + const maxDepth = options?.maxDepth; + const matches = + options?.matches ?? + ((item: SearchResultItem) => item.key.toLowerCase().includes(query.toLowerCase())); + const onMatch = options?.onMatch; log.trace( - { bucket: this.bucket, prefix, key_count: keys.length }, - 'recursive listing complete' + { + bucket: this.bucket, + query, + prefix, + max_depth: maxDepth + }, + 'S3 ListObjectsV2 (search)' ); - return keys; + + if (signal?.aborted) { + throw new DOMException('The operation was aborted', 'AbortError'); + } + + const results: SearchResultItem[] = []; + + await withS3Errors( + () => + this.listAllKeysProgressively( + prefix, + (batch) => { + for (const item of batch) { + if (signal?.aborted) { + throw new DOMException('The operation was aborted', 'AbortError'); + } + const relativeKey = item.key.slice(prefix.length); + if ( + maxDepth !== undefined && + relativeKey.split('/').filter(Boolean).length > maxDepth + ) { + continue; + } + const result = { + key: item.key, + size: item.size, + lastModified: item.lastModified ?? new Date(0), + isDirectory: item.key.endsWith('/') + }; + if (matches(result)) { + results.push(result); + onMatch?.(result); + } + } + return undefined; + }, + { signal } + ), + { bucket: this.bucket, operation: 'search' } + ); + + log.info({ bucket: this.bucket, query, result_count: results.length }, 'search complete'); + return { results }; + } + + async prefixExists(prefix: string, signal?: AbortSignal): Promise { + log.trace({ bucket: this.bucket, prefix }, 'S3 ListObjectsV2 (prefix exists)'); + const output = await withS3Errors( + () => + this.client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: prefix, + MaxKeys: 1, + EncodingType: LIST_ENCODING_TYPE + }), + { abortSignal: signal } + ), + { bucket: this.bucket, operation: 'prefixExists' } + ); + return (output.Contents ?? []).length > 0; + } + + async copyObject( + sourceKey: string, + destKey: string, + options: CopyObjectOptions = {} + ): Promise { + const { onProgress, signal } = options; + const sourceBucket = options.sourceBucket ?? this.bucket; + const copySource = `/${sourceBucket}/${encodeURIComponent(sourceKey)}`; + // The HEAD reads the source; every later request writes the destination. + const sourceContext = { bucket: sourceBucket, key: sourceKey, operation: 'copyObject' }; + const context = { bucket: this.bucket, key: destKey, operation: 'copyObject' }; + log.trace( + { + bucket: this.bucket, + source_bucket: sourceBucket, + source_key: sourceKey, + dest_key: destKey + }, + 'S3 CopyObject' + ); + + // S3 CopyObject has a 5 GB limit. For larger objects we use server-side + // multipart copy (UploadPartCopy) so data never streams through the server. + const HEAD_LIMIT = 5 * 1024 * 1024 * 1024; + const copySingle = async () => { + try { + await this.client.send( + new CopyObjectCommand({ Bucket: this.bucket, CopySource: copySource, Key: destKey }), + { abortSignal: signal } + ); + } catch (err) { + // A missing key here is always the source; name it, not the destination. + const missingSource = err instanceof Error && err.name === 'NoSuchKey'; + mapS3ErrorToHttp(err, missingSource ? sourceContext : context); + } + }; + + // A known small size needs no HEAD; the multipart path still reads it for the ETag pin. + if (options.size !== undefined && options.size <= HEAD_LIMIT) { + await copySingle(); + return; + } + + const head = await withS3Errors( + () => + this.client.send(new HeadObjectCommand({ Bucket: sourceBucket, Key: sourceKey }), { + abortSignal: signal + }), + sourceContext + ); + const size = head.ContentLength ?? 0; + + if (size <= HEAD_LIMIT) { + await copySingle(); + return; + } + + log.info( + { bucket: this.bucket, source_bucket: sourceBucket, source_key: sourceKey, size }, + 'object exceeds CopyObject limit, using server-side multipart copy' + ); + + await withS3Errors(async () => { + // 256 MiB parts — well within the 10 000-part limit even for multi-TB objects + const PART_SIZE = 256 * 1024 * 1024; + const numParts = Math.ceil(size / PART_SIZE); + + // Not tied to `signal`: an upload created while the request is aborted would + // have no ID to clean up with. A cancel is checked right after instead. + const { UploadId } = await this.client.send( + new CreateMultipartUploadCommand({ + Bucket: this.bucket, + Key: destKey, + ContentType: head.ContentType ?? 'application/octet-stream' + }) + ); + const uploadId = UploadId!; + + const parts: Array<{ PartNumber: number; ETag: string }> = []; + try { + signal?.throwIfAborted(); + for (let i = 0; i < numParts; i++) { + const partNumber = i + 1; + const startByte = i * PART_SIZE; + const endByte = Math.min(startByte + PART_SIZE - 1, size - 1); + + const { CopyPartResult } = await this.client.send( + new UploadPartCopyCommand({ + Bucket: this.bucket, + Key: destKey, + UploadId: uploadId, + PartNumber: partNumber, + CopySource: copySource, + CopySourceRange: `bytes=${startByte}-${endByte}`, + // Pin every part to the version the HEAD saw: if the source is + // overwritten mid-copy, S3 answers 412 instead of mixing versions. + CopySourceIfMatch: head.ETag + }), + { abortSignal: signal } + ); + + parts.push({ + PartNumber: partNumber, + ETag: CopyPartResult?.ETag ?? '' + }); + + if (onProgress) { + onProgress(Math.min((i + 1) * PART_SIZE, size), size); + } + } + + // Not tied to `signal` either: once every part is copied, aborting the + // completion could leave it unknown whether the object was written. + signal?.throwIfAborted(); + await this.client.send( + new CompleteMultipartUploadCommand({ + Bucket: this.bucket, + Key: destKey, + UploadId: uploadId, + MultipartUpload: { Parts: parts } + }) + ); + } catch (err) { + // Not tied to `signal`: an aborted copy must still release its parts. + try { + await this.client.send( + new AbortMultipartUploadCommand({ + Bucket: this.bucket, + Key: destKey, + UploadId: uploadId + }) + ); + } catch { + // best-effort cleanup + } + throw err; + } + }, context); } } diff --git a/src/lib/server/storage/service.test.ts b/src/lib/server/storage/service.test.ts deleted file mode 100644 index cce7137f..00000000 --- a/src/lib/server/storage/service.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { faker } from '@faker-js/faker'; -import { S3ServiceException } from '@aws-sdk/client-s3'; - -vi.mock('$lib/server/logging', () => ({ - logger: { child: () => ({ info: vi.fn(), debug: vi.fn(), warn: vi.fn() }) } -})); - -const mockSend = vi.fn(); -vi.mock('./s3-client.js', () => ({ - createS3Client: () => ({ send: (...args: unknown[]) => mockSend(...args) }) -})); - -const mockProvider = { - listObjects: vi.fn(), - getObject: vi.fn(), - getObjectRange: vi.fn(), - getMetadata: vi.fn(), - exists: vi.fn(), - putObject: vi.fn(), - deleteObjects: vi.fn(), - listAllKeys: vi.fn() -}; -vi.mock('./utils.js', () => ({ - getProvider: () => mockProvider -})); - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const mockMapS3ErrorToHttp = vi.fn((err: unknown, _context?: unknown) => { - throw err; -}); -vi.mock('./s3-errors.js', () => ({ - mapS3ErrorToHttp: (err: unknown, context: unknown) => mockMapS3ErrorToHttp(err, context) -})); - -function makeS3Error(name = 'NoSuchKey'): S3ServiceException { - return new S3ServiceException({ name, message: name, $fault: 'client', $metadata: {} }); -} - -import { - listBuckets, - listObjects, - downloadObject, - getObjectMetadata, - uploadObject, - deleteObjects -} from './service.js'; -import type { S3ConnectionConfig } from './types.js'; - -const config: S3ConnectionConfig = { - type: 's3', - host: 'minio.example.com', - accessStyle: 'Path', - region: { name: faker.location.countryCode() } -}; - -describe('storage service', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('listBuckets', () => { - it('returns bucket names', async () => { - mockSend.mockResolvedValue({ Buckets: [{ Name: 'a' }, { Name: 'b' }] }); - expect(await listBuckets(config)).toEqual(['a', 'b']); - }); - - it('filters out buckets with no name', async () => { - mockSend.mockResolvedValue({ Buckets: [{ Name: 'a' }, { Name: undefined }, { Name: '' }] }); - expect(await listBuckets(config)).toEqual(['a']); - }); - - it('handles null Buckets in response', async () => { - mockSend.mockResolvedValue({ Buckets: null }); - expect(await listBuckets(config)).toEqual([]); - }); - }); - - describe('listObjects', () => { - it('proxies to provider', async () => { - const page = { objects: [], prefixes: [], nextToken: null }; - mockProvider.listObjects.mockResolvedValue(page); - const result = await listObjects(config, 'bucket', 'prefix/', 100); - expect(mockProvider.listObjects).toHaveBeenCalledWith('prefix/', 100, undefined); - expect(result).toEqual(page); - }); - - it('passes continuation token when provided', async () => { - const page = { objects: [], prefixes: [], nextToken: null }; - mockProvider.listObjects.mockResolvedValue(page); - await listObjects(config, 'bucket', '', 10, 'tok'); - expect(mockProvider.listObjects).toHaveBeenCalledWith('', 10, 'tok'); - }); - - it('passes undefined when continuation token is null', async () => { - const page = { objects: [], prefixes: [], nextToken: null }; - mockProvider.listObjects.mockResolvedValue(page); - await listObjects(config, 'bucket', '', 10, null); - expect(mockProvider.listObjects).toHaveBeenCalledWith('', 10, undefined); - }); - - it('throws S3ServiceException via mapS3ErrorToHttp', async () => { - const err = makeS3Error('AccessDenied'); - mockProvider.listObjects.mockRejectedValue(err); - await expect(listObjects(config, 'bucket', '', 10)).rejects.toThrow(err); - expect(mockMapS3ErrorToHttp).toHaveBeenCalledWith(err, { - bucket: 'bucket', - operation: 'listObjects' - }); - }); - - it('rethrows non-S3 errors', async () => { - const err = new Error('network'); - mockProvider.listObjects.mockRejectedValue(err); - await expect(listObjects(config, 'bucket', '', 10)).rejects.toThrow('network'); - expect(mockMapS3ErrorToHttp).not.toHaveBeenCalled(); - }); - }); - - describe('downloadObject', () => { - it('returns download from provider', async () => { - const download = { stream: new ReadableStream(), contentType: 'text/plain' }; - mockProvider.getObject.mockResolvedValue(download); - const result = await downloadObject(config, 'bucket', 'key.txt'); - expect(result).toEqual(download); - }); - - it('throws S3ServiceException via mapS3ErrorToHttp', async () => { - const err = makeS3Error('NoSuchKey'); - mockProvider.getObject.mockRejectedValue(err); - await expect(downloadObject(config, 'bucket', 'key.txt')).rejects.toThrow(err); - expect(mockMapS3ErrorToHttp).toHaveBeenCalledWith(err, { - bucket: 'bucket', - key: 'key.txt', - operation: 'getObject' - }); - }); - - it('rethrows non-S3 errors', async () => { - const err = new Error('timeout'); - mockProvider.getObject.mockRejectedValue(err); - await expect(downloadObject(config, 'bucket', 'k')).rejects.toThrow('timeout'); - expect(mockMapS3ErrorToHttp).not.toHaveBeenCalled(); - }); - }); - - describe('getObjectMetadata', () => { - it('proxies to provider', async () => { - const meta = { contentType: 'text/plain', contentLength: 42 }; - mockProvider.getMetadata.mockResolvedValue(meta); - expect(await getObjectMetadata(config, 'bucket', 'k')).toEqual(meta); - }); - - it('throws S3ServiceException via mapS3ErrorToHttp', async () => { - const err = makeS3Error('NoSuchKey'); - mockProvider.getMetadata.mockRejectedValue(err); - await expect(getObjectMetadata(config, 'bucket', 'k')).rejects.toThrow(err); - expect(mockMapS3ErrorToHttp).toHaveBeenCalledWith(err, { - bucket: 'bucket', - key: 'k', - operation: 'getMetadata' - }); - }); - - it('rethrows non-S3 errors', async () => { - const err = new Error('gone'); - mockProvider.getMetadata.mockRejectedValue(err); - await expect(getObjectMetadata(config, 'bucket', 'k')).rejects.toThrow('gone'); - expect(mockMapS3ErrorToHttp).not.toHaveBeenCalled(); - }); - }); - - describe('uploadObject', () => { - it('proxies to provider', async () => { - mockProvider.putObject.mockResolvedValue(undefined); - await uploadObject(config, 'bucket', 'k', Buffer.from('x'), 'text/plain', 1); - expect(mockProvider.putObject).toHaveBeenCalledWith('k', expect.any(Buffer), 'text/plain', 1); - }); - - it('throws S3ServiceException via mapS3ErrorToHttp', async () => { - const err = makeS3Error('AccessDenied'); - mockProvider.putObject.mockRejectedValue(err); - await expect( - uploadObject(config, 'bucket', 'k', Buffer.from('x'), 'text/plain') - ).rejects.toThrow(err); - expect(mockMapS3ErrorToHttp).toHaveBeenCalledWith(err, { - bucket: 'bucket', - key: 'k', - operation: 'putObject' - }); - }); - - it('rethrows non-S3 errors', async () => { - const err = new Error('disk full'); - mockProvider.putObject.mockRejectedValue(err); - await expect( - uploadObject(config, 'bucket', 'k', Buffer.from('x'), 'text/plain') - ).rejects.toThrow('disk full'); - expect(mockMapS3ErrorToHttp).not.toHaveBeenCalled(); - }); - }); - - describe('deleteObjects', () => { - it('expands directory prefixes and deletes all', async () => { - mockProvider.listAllKeys.mockResolvedValue(['dir/a.txt', 'dir/b.txt']); - mockProvider.deleteObjects.mockResolvedValue({ failed: [] }); - const result = await deleteObjects(config, 'bucket', ['file.txt', 'dir/']); - expect(mockProvider.listAllKeys).toHaveBeenCalledWith('dir/'); - expect(mockProvider.deleteObjects).toHaveBeenCalledWith([ - 'file.txt', - 'dir/a.txt', - 'dir/b.txt' - ]); - expect(result).toEqual({ failed: [] }); - }); - - it('uses directory key itself when no children found', async () => { - mockProvider.listAllKeys.mockResolvedValue([]); - mockProvider.deleteObjects.mockResolvedValue({ failed: [] }); - await deleteObjects(config, 'bucket', ['empty/']); - expect(mockProvider.deleteObjects).toHaveBeenCalledWith(['empty/']); - }); - - it('returns empty result for no keys', async () => { - mockProvider.listAllKeys.mockResolvedValue([]); - mockProvider.deleteObjects.mockResolvedValue({ failed: [] }); - const result = await deleteObjects(config, 'bucket', []); - expect(result).toEqual({ failed: [] }); - }); - - it('throws S3ServiceException via mapS3ErrorToHttp', async () => { - const err = makeS3Error('AccessDenied'); - mockProvider.listAllKeys.mockResolvedValue([]); - mockProvider.deleteObjects.mockRejectedValue(err); - await expect(deleteObjects(config, 'bucket', ['file.txt'])).rejects.toThrow(err); - expect(mockMapS3ErrorToHttp).toHaveBeenCalledWith(err, { - bucket: 'bucket', - operation: 'deleteObjects' - }); - }); - - it('rethrows non-S3 errors', async () => { - const err = new Error('network'); - mockProvider.deleteObjects.mockRejectedValue(err); - await expect(deleteObjects(config, 'bucket', ['file.txt'])).rejects.toThrow('network'); - expect(mockMapS3ErrorToHttp).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/lib/server/storage/service.ts b/src/lib/server/storage/service.ts deleted file mode 100644 index c27b6532..00000000 --- a/src/lib/server/storage/service.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { S3ServiceException, ListBucketsCommand } from '@aws-sdk/client-s3'; -import { createS3Client } from './s3-client.js'; -import { mapS3ErrorToHttp } from './s3-errors.js'; -import { getProvider } from './utils.js'; -import type { StoragePage, StorageMetadata } from '$lib/storage/types.js'; -import type { ObjectDownload, DeleteObjectsResult } from './provider.js'; -import type { S3ConnectionConfig } from './types.js'; -import { logger } from '$lib/server/logging'; - -const log = logger.child({ module: 'storage-service' }); - -/** List all buckets accessible with the given connection config. */ -export async function listBuckets(config: S3ConnectionConfig): Promise { - const client = createS3Client(config); - - const output = await client.send(new ListBucketsCommand({})); - const buckets = (output.Buckets ?? []).map((b) => b.Name ?? '').filter(Boolean); - log.debug({ storage_type: config.type, bucket_count: buckets.length }, 'listed buckets'); - return buckets; -} - -/** List objects at the given bucket/prefix. Throws on S3 errors. */ -export async function listObjects( - config: S3ConnectionConfig, - bucket: string, - prefix: string, - pageSize: number, - continuationToken?: string | null -): Promise { - const provider = getProvider(config, bucket); - - try { - return await provider.listObjects(prefix, pageSize, continuationToken ?? undefined); - } catch (err) { - if (err instanceof S3ServiceException) { - mapS3ErrorToHttp(err, { bucket, operation: 'listObjects' }); - } - throw err; - } -} - -/** Download a single object from the bucket, returning a stream and metadata for the HTTP response. */ -export async function downloadObject( - config: S3ConnectionConfig, - bucket: string, - key: string -): Promise { - const provider = getProvider(config, bucket); - - try { - log.debug({ bucket, key }, 'downloading object'); - const download = await provider.getObject(key); - log.info({ bucket, key }, 'object download started'); - return download; - } catch (err) { - if (err instanceof S3ServiceException) { - mapS3ErrorToHttp(err, { bucket, key, operation: 'getObject' }); - } - throw err; - } -} - -/** Fetch metadata for a single object — used for lightweight pre-flight checks. */ -export async function getObjectMetadata( - config: S3ConnectionConfig, - bucket: string, - key: string -): Promise { - const provider = getProvider(config, bucket); - - try { - log.debug({ bucket, key }, 'getting object metadata'); - return await provider.getMetadata(key); - } catch (err) { - if (err instanceof S3ServiceException) { - mapS3ErrorToHttp(err, { bucket, key, operation: 'getMetadata' }); - } - throw err; - } -} - -/** Upload an object to the bucket, using multipart upload for large files. */ -export async function uploadObject( - config: S3ConnectionConfig, - bucket: string, - key: string, - body: ReadableStream | Buffer, - contentType: string, - contentLength?: number -): Promise { - const provider = getProvider(config, bucket); - - try { - log.debug({ bucket, key, content_type: contentType }, 'uploading object'); - await provider.putObject(key, body, contentType, contentLength); - log.info( - { bucket, key, content_type: contentType, content_length: contentLength }, - 'object uploaded' - ); - } catch (err) { - if (err instanceof S3ServiceException) { - mapS3ErrorToHttp(err, { bucket, key, operation: 'putObject' }); - } - throw err; - } -} - -/** Delete one or more objects from the bucket. Returns a result listing any keys that failed. - * Directory keys (ending with '/') are expanded to all contained objects before deletion. */ -export async function deleteObjects( - config: S3ConnectionConfig, - bucket: string, - keys: string[] -): Promise { - const provider = getProvider(config, bucket); - - try { - // Expand any directory prefixes (keys ending with '/') to their contents. - const dirPrefixes = keys.filter((k) => k.endsWith('/')); - const fileKeys = keys.filter((k) => !k.endsWith('/')); - - let allKeys = [...fileKeys]; - for (const prefix of dirPrefixes) { - log.debug({ bucket, prefix }, 'expanding directory prefix for deletion'); - const children = await provider.listAllKeys(prefix); - allKeys = allKeys.concat(children.length > 0 ? children : [prefix]); - } - - if (allKeys.length === 0) { - return { failed: [] }; - } - - log.debug({ bucket, key_count: allKeys.length }, 'deleting objects'); - const result = await provider.deleteObjects(allKeys); - log.info( - { bucket, key_count: allKeys.length, failed_count: result.failed.length }, - 'objects delete completed' - ); - return result; - } catch (err) { - if (err instanceof S3ServiceException) { - mapS3ErrorToHttp(err, { bucket, operation: 'deleteObjects' }); - } - throw err; - } -} diff --git a/src/lib/server/storage/transfer/execute.test.ts b/src/lib/server/storage/transfer/execute.test.ts new file mode 100644 index 00000000..1c259542 --- /dev/null +++ b/src/lib/server/storage/transfer/execute.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it, vi } from 'vitest'; +import { error } from '@sveltejs/kit'; +import { executeTransfer, type TransferContext } from './execute.js'; +import type { TransferRequest } from './resolve.js'; +import { + keysOf, + memoryProvider, + memoryStore, + type MemoryStore +} from './memory-provider.test-utils.js'; +import { createMockLogger } from '$lib/test-utils/mock-logger.js'; +import type { TransferEvent, TransferItemRequest } from '$lib/storage/transfer-types.js'; + +function setup(store: MemoryStore, sourceBucket = 'b', destBucket = sourceBucket) { + const source = memoryProvider(store, sourceBucket); + const dest = sourceBucket === destBucket ? source : memoryProvider(store, destBucket); + const events: TransferEvent[] = []; + const controller = new AbortController(); + const logger = createMockLogger(); + const ctx: TransferContext = { + source, + dest, + concurrency: 1, + signal: controller.signal, + logger, + emit: (event) => events.push(event) + }; + const run = ( + op: TransferRequest['op'], + items: Array, + destPrefix = 'dest/' + ) => + executeTransfer( + { + op, + sourceBucket, + destBucket, + destPrefix, + items: items.map((item) => (typeof item === 'string' ? { sourceKey: item } : item)) + }, + ctx + ); + return { source, dest, events, controller, logger, ctx, run }; +} + +/** Track how many copies run at once; each one waits a tick so they overlap. */ +function trackPeak(provider: ReturnType['source']) { + const stats = { running: 0, peak: 0 }; + const copy = provider.copyObject.getMockImplementation()!; + provider.copyObject.mockImplementation(async (...args) => { + stats.peak = Math.max(stats.peak, ++stats.running); + await new Promise((resolve) => setTimeout(resolve, 1)); + stats.running--; + return copy(...args); + }); + return stats; +} + +describe('executeTransfer', () => { + it('copies a folder with its marker and reports one result per selected item', async () => { + const store = memoryStore({ b: { 'docs/': 0, 'docs/a.txt': 3, 'docs/n/b.txt': 4 } }); + const { events, run } = setup(store); + + const { items, summary } = await run('copy', ['docs/']); + + expect(keysOf(store, 'b')).toEqual([ + 'dest/docs/', + 'dest/docs/a.txt', + 'dest/docs/n/b.txt', + 'docs/', + 'docs/a.txt', + 'docs/n/b.txt' + ]); + expect(events[0]).toEqual({ + type: 'item-start', + sourceKey: 'docs/', + destRoot: 'dest/docs/', + totalObjects: 3, + totalBytes: 7 + }); + expect(items).toEqual([ + { + sourceKey: 'docs/', + destRoot: 'dest/docs/', + status: 'done', + copied: 3, + failed: [], + deletedOriginals: 0 + } + ]); + expect(summary).toEqual({ done: 1, partial: 0, failed: 0, refused: 0, cancelled: 0 }); + expect(events.at(-1)).toEqual({ type: 'complete', summary }); + }); + + it('copies between buckets server-side through the destination provider', async () => { + const store = memoryStore({ src: ['a.txt'], dst: [] }); + const { source, dest, run } = setup(store, 'src', 'dst'); + + await run('move', ['a.txt'], ''); + + expect(dest.copyObject).toHaveBeenCalledWith( + 'a.txt', + 'a.txt', + expect.objectContaining({ sourceBucket: 'src' }) + ); + expect(keysOf(store, 'dst')).toEqual(['a.txt']); + expect(source.deleteObjects).toHaveBeenCalledWith(['a.txt'], false); + expect(keysOf(store, 'src')).toEqual([]); + }); + + it('merges folders: matching keys are overwritten, destination-only objects kept', async () => { + const store = memoryStore({ + b: { 'docs/a.txt': 5, 'dest/docs/a.txt': 1, 'dest/docs/only-here.txt': 1 } + }); + const { source, run } = setup(store); + + const { items } = await run('copy', [{ sourceKey: 'docs/', policy: 'merge' }]); + + expect(items[0].status).toBe('done'); + expect(store.get('b')!.get('dest/docs/a.txt')).toBe(5); + expect(keysOf(store, 'b')).toContain('dest/docs/only-here.txt'); + expect(source.deleteObjects).not.toHaveBeenCalled(); + }); + + it('replaces a file without deleting the destination first', async () => { + const store = memoryStore({ b: { 'a.txt': 5, 'dest/a.txt': 1 } }); + const { source, run } = setup(store); + + const { items } = await run('copy', [{ sourceKey: 'a.txt', policy: 'replace' }]); + + expect(items[0].status).toBe('done'); + expect(store.get('b')!.get('dest/a.txt')).toBe(5); + expect(source.deleteObjects).not.toHaveBeenCalled(); + }); + + it('returns the numbered name of a keepBoth item', async () => { + const store = memoryStore({ b: ['a.txt', 'dest/a.txt'] }); + const { run } = setup(store); + + const { items } = await run('copy', [{ sourceKey: 'a.txt', policy: 'keepBoth' }]); + + expect(items[0]).toMatchObject({ status: 'done', destRoot: 'dest/a (1).txt' }); + }); + + it('deletes exactly the copied keys, so an object added mid-move survives', async () => { + const store = memoryStore({ b: ['docs/', 'docs/a.txt'] }); + const { source, run } = setup(store); + source.copyObject.mockImplementationOnce(async (from, to) => { + store.get('b')!.set(to, 1); + store.get('b')!.set('docs/late.txt', 1); + }); + + const { items } = await run('move', ['docs/']); + + expect(source.deleteObjects).toHaveBeenCalledWith(['docs/', 'docs/a.txt'], false); + expect(items[0]).toMatchObject({ status: 'done', copied: 2, deletedOriginals: 2 }); + expect(keysOf(store, 'b')).toEqual(['dest/docs/', 'dest/docs/a.txt', 'docs/late.txt']); + }); + + it('keeps every original of a partially copied move', async () => { + const store = memoryStore({ b: ['docs/a.txt', 'docs/b.txt'] }); + const { source, logger, run } = setup(store); + source.copyObject.mockImplementationOnce(async () => { + throw error(403, 'Access denied to "docs/a.txt"'); + }); + + const { items } = await run('move', ['docs/']); + + expect(items[0]).toMatchObject({ + status: 'partial', + copied: 1, + failed: [{ key: 'docs/a.txt', error: 'Access denied to "docs/a.txt"' }], + deletedOriginals: 0 + }); + expect(source.deleteObjects).not.toHaveBeenCalled(); + expect(keysOf(store, 'b')).toEqual(['dest/docs/b.txt', 'docs/a.txt', 'docs/b.txt']); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ source_key: 'docs/a.txt' }), + 'transfer object copy failed' + ); + }); + + it('reports an item where nothing was copied as failed', async () => { + const store = memoryStore({ b: ['a.txt'] }); + const { source, run } = setup(store); + source.copyObject.mockRejectedValueOnce(new Error('boom')); + + const { items } = await run('copy', ['a.txt', 'missing.txt']); + + expect(items.map((item) => item.status)).toEqual(['failed', 'failed']); + expect(items[1].failed).toEqual([ + { key: 'missing.txt', error: 'Object "missing.txt" not found' } + ]); + }); + + it('reports failed deletes of a move as partial', async () => { + const store = memoryStore({ b: ['a.txt'] }); + const { source, run } = setup(store); + source.deleteObjects.mockResolvedValueOnce({ + failed: [{ key: 'a.txt', message: 'Access denied' }] + }); + + const { items } = await run('move', ['a.txt']); + + expect(items[0]).toMatchObject({ + status: 'partial', + copied: 1, + deletedOriginals: 0, + failed: [{ key: 'a.txt', error: 'Access denied' }] + }); + }); + + it('counts the originals really deleted when a delete call fails midway', async () => { + const store = memoryStore({ b: ['docs/a', 'docs/b', 'docs/c'] }); + const { source, run } = setup(store); + source.deleteObjects.mockImplementationOnce(async (keys) => { + store.get('b')!.delete(keys[0]); + throw new Error('connection reset'); + }); + + const { items } = await run('move', ['docs/']); + + expect(items[0]).toMatchObject({ + status: 'partial', + copied: 3, + deletedOriginals: 1, + failed: [{ key: 'docs/', error: 'connection reset' }] + }); + }); + + it('refuses a folder whose children would exceed the key limit, copying nothing', async () => { + const child = `${'x'.repeat(1015)}.txt`; + const store = memoryStore({ b: ['docs/', `docs/${child}`] }); + const { source, run } = setup(store); + + const { items } = await run('copy', ['docs/'], 'longer-destination/'); + + expect(items[0]).toMatchObject({ status: 'refused', reason: 'name_too_long', copied: 0 }); + expect(source.copyObject).not.toHaveBeenCalled(); + }); + + it('reports refused items without touching storage', async () => { + const store = memoryStore({ b: ['a.txt', 'dest/a.txt'] }); + const { source, events, run } = setup(store); + + const { items } = await run('move', ['a.txt']); + + expect(items).toEqual([ + { + sourceKey: 'a.txt', + status: 'refused', + reason: 'conflict', + copied: 0, + failed: [], + deletedOriginals: 0 + } + ]); + expect(source.copyObject).not.toHaveBeenCalled(); + expect(events.map((event) => event.type)).toEqual(['item-done', 'complete']); + }); + + it('never merges a folder into an ancestor that contains it, so no source is lost', async () => { + const store = memoryStore({ b: { 'y/y/f': 1, 'y/y/y/f': 2 } }); + const { source, run } = setup(store); + + const { items } = await run('move', [{ sourceKey: 'y/y/', policy: 'merge' }], ''); + + expect(items[0]).toMatchObject({ status: 'refused', reason: 'overlaps_source' }); + expect(source.copyObject).not.toHaveBeenCalled(); + expect(Object.fromEntries(store.get('b')!)).toEqual({ 'y/y/f': 1, 'y/y/y/f': 2 }); + }); + + it('renames a folder within its parent', async () => { + const store = memoryStore({ b: ['a/old/', 'a/old/x.txt'] }); + const { run } = setup(store); + + const { items } = await run('rename', [{ sourceKey: 'a/old/', name: 'new' }], ''); + + expect(items[0]).toMatchObject({ status: 'done', destRoot: 'a/new/' }); + expect(keysOf(store, 'b')).toEqual(['a/new/', 'a/new/x.txt']); + }); + + describe('cancellation', () => { + it('finishes the deletes of a running item and cancels the next one', async () => { + const store = memoryStore({ b: ['one/a.txt', 'one/b.txt', 'two.txt'] }); + const { source, controller, run } = setup(store); + const deleteObjects = source.deleteObjects.getMockImplementation()!; + source.deleteObjects.mockImplementationOnce(async (keys, expand) => { + controller.abort(); + return deleteObjects(keys, expand); + }); + + const { items } = await run('move', ['one/', 'two.txt']); + + expect(items[0]).toMatchObject({ status: 'done', deletedOriginals: 2 }); + expect(items[1]).toMatchObject({ status: 'cancelled', copied: 0 }); + expect(keysOf(store, 'b')).toEqual(['dest/one/a.txt', 'dest/one/b.txt', 'two.txt']); + }); + + it('keeps all originals of a move cancelled while copying', async () => { + const store = memoryStore({ b: ['one/a.txt', 'one/b.txt', 'one/c.txt', 'two.txt'] }); + const { source, controller, events, run } = setup(store); + const copy = source.copyObject.getMockImplementation()!; + source.copyObject.mockImplementationOnce(async (from, to, options) => { + await copy(from, to, options); + controller.abort(); + }); + + const { items } = await run('move', ['one/', 'two.txt']); + + expect(items.map((item) => item.status)).toEqual(['cancelled', 'cancelled']); + expect(items[0]).toMatchObject({ copied: 1, failed: [] }); + expect(source.deleteObjects).not.toHaveBeenCalled(); + expect(keysOf(store, 'b')).toEqual([ + 'dest/one/a.txt', + 'one/a.txt', + 'one/b.txt', + 'one/c.txt', + 'two.txt' + ]); + expect(events.at(-1)).toMatchObject({ type: 'complete' }); + }); + + it('treats a copy aborted mid-flight as interrupted, not failed', async () => { + const store = memoryStore({ b: ['a.txt'] }); + const { source, controller, run } = setup(store); + source.copyObject.mockImplementationOnce(async () => { + controller.abort(); + throw error(502, 'aborted'); + }); + + const { items } = await run('copy', ['a.txt']); + + expect(items[0]).toMatchObject({ status: 'cancelled', copied: 0, failed: [] }); + }); + }); + + it('always ends with complete, even when an existence check fails', async () => { + const store = memoryStore({ b: ['a.txt'] }); + const { source, events, run } = setup(store); + source.exists.mockImplementationOnce(async () => { + throw error(403, 'Access denied'); + }); + + const { items } = await run('copy', ['a.txt']); + + expect(items[0]).toMatchObject({ + status: 'failed', + failed: [{ key: 'a.txt', error: 'Access denied' }] + }); + expect(events.map((event) => event.type)).toEqual(['item-done', 'complete']); + }); + + it('copies objects of one item through the concurrency pool', async () => { + const keys = Array.from({ length: 6 }, (_, i) => `docs/${i}`); + const store = memoryStore({ b: keys }); + const { source } = setup(store); + let running = 0; + let peak = 0; + const copy = source.copyObject.getMockImplementation()!; + source.copyObject.mockImplementation(async (...args) => { + peak = Math.max(peak, ++running); + await new Promise((resolve) => setTimeout(resolve, 1)); + running--; + return copy(...args); + }); + + await executeTransfer( + { + op: 'copy', + sourceBucket: 'b', + destBucket: 'b', + destPrefix: 'dest/', + items: [{ sourceKey: 'docs/' }] + }, + { + source, + dest: source, + concurrency: 3, + signal: new AbortController().signal, + logger: createMockLogger(), + emit: vi.fn() + } + ); + + expect(peak).toBe(3); + }); + + describe('concurrent items', () => { + it('runs single-file items through the concurrency pool', async () => { + const keys = Array.from({ length: 6 }, (_, i) => `f${i}.txt`); + const { source, ctx, run } = setup(memoryStore({ b: keys })); + ctx.concurrency = 3; + const stats = trackPeak(source); + + const { summary } = await run('copy', keys); + + expect(stats.peak).toBe(3); + expect(summary.done).toBe(6); + }); + + it('shares one copy budget between items', async () => { + const keys = ['a/', 'b/'].flatMap((folder) => + Array.from({ length: 4 }, (_, i) => `${folder}${i}`) + ); + const { source, ctx, run } = setup(memoryStore({ b: keys })); + ctx.concurrency = 3; + const stats = trackPeak(source); + + const { summary } = await run('copy', ['a/', 'b/']); + + expect(stats.peak).toBe(3); + expect(summary.done).toBe(2); + }); + + it('returns results in request order when a later item finishes first', async () => { + const { source, ctx, events, run } = setup(memoryStore({ b: ['slow.txt', 'fast.txt'] })); + ctx.concurrency = 2; + const copy = source.copyObject.getMockImplementation()!; + source.copyObject.mockImplementation(async (from, to, options) => { + if (from === 'slow.txt') await new Promise((resolve) => setTimeout(resolve, 5)); + return copy(from, to, options); + }); + + const { items } = await run('copy', ['slow.txt', 'fast.txt']); + + expect(items.map((item) => item.sourceKey)).toEqual(['slow.txt', 'fast.txt']); + const done = events.filter((event) => event.type === 'item-done'); + expect(done.map((event) => event.sourceKey)).toEqual(['fast.txt', 'slow.txt']); + }); + + it('gives every item a result when cancelled while items run in parallel', async () => { + const keys = Array.from({ length: 5 }, (_, i) => `f${i}.txt`); + const store = memoryStore({ b: keys }); + const { source, ctx, controller, events, run } = setup(store); + ctx.concurrency = 2; + const copy = source.copyObject.getMockImplementation()!; + source.copyObject.mockImplementationOnce(async (from, to, options) => { + await copy(from, to, options); + controller.abort(); + }); + + const { items, summary } = await run('move', keys); + + expect(items).toHaveLength(5); + expect(summary.cancelled + summary.done).toBe(5); + // A cancelled item of a move keeps its original. + const cancelled = items.filter((item) => item.status === 'cancelled'); + expect(cancelled.length).toBeGreaterThan(0); + for (const item of cancelled) { + expect(item.deletedOriginals).toBe(0); + expect(keysOf(store, 'b')).toContain(item.sourceKey); + } + expect(events.filter((event) => event.type === 'item-done')).toHaveLength(5); + expect(events.at(-1)).toMatchObject({ type: 'complete' }); + }); + + it('passes the known size to the copy, so the provider can skip reading the source', async () => { + const { source, run } = setup(memoryStore({ b: ['a.txt', 'docs/b.txt'] })); + + await run('copy', ['a.txt', 'docs/']); + + for (const [, , options] of source.copyObject.mock.calls) { + expect(options?.size).toEqual(expect.any(Number)); + } + expect(source.copyObject).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/lib/server/storage/transfer/execute.ts b/src/lib/server/storage/transfer/execute.ts new file mode 100644 index 00000000..294a93ba --- /dev/null +++ b/src/lib/server/storage/transfer/execute.ts @@ -0,0 +1,329 @@ +import type pino from 'pino'; +import type { StorageProvider } from '../provider.js'; +import type { + TransferEvent, + TransferItemResult, + TransferObjectFailure, + TransferSummary +} from '$lib/storage/transfer-types.js'; +import { createLimiter, runPool } from './pool.js'; +import { + isTooLong, + resolveItems, + transferErrorMessage, + type ResolvedItem, + type TransferRequest +} from './resolve.js'; + +/** Progress events of one item are sent at most this often. */ +const PROGRESS_INTERVAL_MS = 250; + +export interface TransferContext { + /** Provider of the source bucket; originals of a move are deleted here. */ + source: StorageProvider; + /** Provider of the destination bucket; copies run here. */ + dest: StorageProvider; + /** Objects copied at once across the whole request; items also run this many at a time. */ + concurrency: number; + /** Cancels the transfer: running S3 calls are aborted and no further item starts. */ + signal: AbortSignal; + logger: pino.Logger; + emit: (event: TransferEvent) => void; +} + +interface SourceObject { + key: string; + size: number; +} + +function emptySummary(): TransferSummary { + return { done: 0, partial: 0, failed: 0, refused: 0, cancelled: 0 }; +} + +function result( + item: ResolvedItem, + fields: Partial & Pick +): TransferItemResult { + return { sourceKey: item.sourceKey, copied: 0, failed: [], deletedOriginals: 0, ...fields }; +} + +/** Every object of an item, with sizes. An empty list means the source is gone. */ +async function listSource(item: ResolvedItem, ctx: TransferContext): Promise { + if (item.kind === 'file') { + const meta = await ctx.source.getMetadata(item.sourceKey); + return [{ key: item.sourceKey, size: meta.size }]; + } + const objects: SourceObject[] = []; + await ctx.source.listAllKeysProgressively( + item.sourceKey, + (batch) => { + for (const { key, size } of batch) objects.push({ key, size }); + }, + { signal: ctx.signal } + ); + return objects; +} + +async function runItem( + item: ResolvedItem, + request: TransferRequest, + ctx: TransferContext, + limit: ReturnType +): Promise { + const { signal, logger } = ctx; + const destRoot = item.destKey; + const deleteOriginals = request.op !== 'copy'; + + let objects: SourceObject[]; + try { + objects = await listSource(item, ctx); + } catch (err) { + if (signal.aborted) return result(item, { status: 'cancelled' }); + return result(item, { + status: 'failed', + failed: [{ key: item.sourceKey, error: transferErrorMessage(err) }] + }); + } + if (objects.length === 0) { + return result(item, { + status: 'failed', + failed: [{ key: item.sourceKey, error: 'Source not found' }] + }); + } + const destKeyFor = (object: SourceObject) => + item.kind === 'file' ? destRoot : destRoot + object.key.slice(item.sourceKey.length); + // A child can exceed the key limit even when the folder name fits; refuse before copying anything. + if (objects.some((object) => isTooLong(destKeyFor(object)))) { + return result(item, { status: 'refused', reason: 'name_too_long' }); + } + + const totalBytes = objects.reduce((sum, object) => sum + object.size, 0); + ctx.emit({ + type: 'item-start', + sourceKey: item.sourceKey, + destRoot, + totalObjects: objects.length, + totalBytes + }); + + const copied: SourceObject[] = []; + const failed: TransferObjectFailure[] = []; + let copiedBytes = 0; + // Bytes of multipart copies still running, so large objects show progress. + const partialBytes = new Map(); + let lastProgress = 0; + const reportProgress = () => { + const now = Date.now(); + if (now - lastProgress < PROGRESS_INTERVAL_MS) return; + lastProgress = now; + let inFlight = 0; + for (const bytes of partialBytes.values()) inFlight += bytes; + ctx.emit({ + type: 'progress', + sourceKey: item.sourceKey, + copiedObjects: copied.length, + copiedBytes: copiedBytes + inFlight + }); + }; + + await runPool( + objects, + ctx.concurrency, + async (object) => { + const destKey = destKeyFor(object); + try { + // The limiter is shared by every item, so parallel items never exceed the budget. + await limit(() => + ctx.dest.copyObject(object.key, destKey, { + sourceBucket: request.sourceBucket, + size: object.size, + signal, + onProgress: (loaded) => { + partialBytes.set(object.key, loaded); + reportProgress(); + } + }) + ); + copied.push(object); + copiedBytes += object.size; + } catch (err) { + // An aborted call is an interrupted copy, not a failed one. + if (signal.aborted) return; + const error = transferErrorMessage(err); + failed.push({ key: object.key, error }); + logger.warn( + { + source_bucket: request.sourceBucket, + dest_bucket: request.destBucket, + source_key: object.key, + dest_key: destKey, + error + }, + 'transfer object copy failed' + ); + } finally { + partialBytes.delete(object.key); + } + reportProgress(); + }, + signal + ); + + const counts = { destRoot, copied: copied.length, failed }; + const complete = copied.length === objects.length; + // A move keeps its originals once cancelled, even when every copy finished. + if (signal.aborted && (!complete || deleteOriginals)) { + return result(item, { status: 'cancelled', ...counts }); + } + if (!complete) { + return result(item, { status: copied.length === 0 ? 'failed' : 'partial', ...counts }); + } + if (!deleteOriginals) return result(item, { status: 'done', ...counts }); + + // Deletes ignore cancellation: once started, the item finishes. Exactly the + // copied keys are deleted, so an object added since the listing survives. + const keys = copied.map((object) => object.key); + try { + const deleted = await ctx.source.deleteObjects(keys, false); + for (const failure of deleted.failed) { + failed.push({ key: failure.key, error: failure.message ?? 'Delete failed' }); + logger.warn( + { source_bucket: request.sourceBucket, key: failure.key }, + 'transfer original delete failed' + ); + } + return result(item, { + status: deleted.failed.length === 0 ? 'done' : 'partial', + ...counts, + deletedOriginals: keys.length - deleted.failed.length + }); + } catch (err) { + // The call can fail after some batches were deleted; count what is really gone. + const error = transferErrorMessage(err); + failed.push({ key: item.sourceKey, error }); + logger.warn( + { source_bucket: request.sourceBucket, key: item.sourceKey, error }, + 'transfer original delete failed' + ); + const remaining = await remainingKeys(item, keys, ctx).catch(() => null); + return result(item, { + status: 'partial', + ...counts, + deletedOriginals: remaining === null ? 0 : keys.length - remaining + }); + } +} + +/** How many of `keys` still exist in the source, after a failed delete call. */ +async function remainingKeys( + item: ResolvedItem, + keys: string[], + ctx: TransferContext +): Promise { + if (item.kind === 'file') return (await ctx.source.exists(item.sourceKey)) ? 1 : 0; + const wanted = new Set(keys); + let remaining = 0; + await ctx.source.listAllKeysProgressively(item.sourceKey, (batch) => { + for (const { key } of batch) if (wanted.has(key)) remaining++; + }); + return remaining; +} + +/** + * Run a validated transfer: resolve every item, then copy (and for a move or + * rename, delete the originals of) the items concurrently. At most + * `concurrency` objects are copied at once across all items. Never throws: + * every item ends in an `item-done` event and the last event is always + * `complete`. Results keep the request order; events arrive as items finish. + */ +export async function executeTransfer( + request: TransferRequest, + ctx: TransferContext +): Promise<{ items: TransferItemResult[]; summary: TransferSummary }> { + const started = Date.now(); + const { signal, logger } = ctx; + const results: TransferItemResult[] = []; + const summary = emptySummary(); + const finish = (itemResult: TransferItemResult, index = results.length) => { + results[index] = itemResult; + summary[itemResult.status]++; + ctx.emit({ type: 'item-done', ...itemResult }); + logger.debug( + { + op: request.op, + source_key: itemResult.sourceKey, + dest_root: itemResult.destRoot, + status: itemResult.status, + reason: itemResult.reason, + copied: itemResult.copied, + failed: itemResult.failed.length, + deleted_originals: itemResult.deletedOriginals + }, + 'transfer item finished' + ); + }; + + let resolved: ResolvedItem[] = []; + try { + resolved = await resolveItems(request, ctx.dest, { + mode: 'execute', + concurrency: ctx.concurrency, + signal + }); + } catch (err) { + logger.error({ op: request.op, err }, 'transfer resolution failed'); + const error = transferErrorMessage(err); + for (const { sourceKey } of request.items) { + finish({ + sourceKey, + status: signal.aborted ? 'cancelled' : 'failed', + copied: 0, + failed: signal.aborted ? [] : [{ key: sourceKey, error }], + deletedOriginals: 0 + }); + } + } + + const limit = createLimiter(ctx.concurrency); + // No signal for the pool: each item checks it itself, so a cancelled item still gets its result. + await runPool(resolved, ctx.concurrency, async (item, index) => { + if (item.refused) { + finish(result(item, { status: 'refused', reason: item.refused }), index); + } else if (signal.aborted) { + finish(result(item, { status: 'cancelled' }), index); + } else if (item.error) { + finish( + result(item, { status: 'failed', failed: [{ key: item.sourceKey, error: item.error }] }), + index + ); + } else { + try { + finish(await runItem(item, request, ctx, limit), index); + } catch (err) { + // runItem handles provider errors itself; this is a bug, not a storage failure. + logger.error({ op: request.op, source_key: item.sourceKey, err }, 'transfer item crashed'); + finish( + result(item, { + status: 'failed', + failed: [{ key: item.sourceKey, error: transferErrorMessage(err) }] + }), + index + ); + } + } + }); + + ctx.emit({ type: 'complete', summary }); + logger.info( + { + op: request.op, + source_bucket: request.sourceBucket, + dest_bucket: request.destBucket, + item_count: request.items.length, + ...summary, + duration_ms: Date.now() - started + }, + 'transfer completed' + ); + return { items: results, summary }; +} diff --git a/src/lib/server/storage/transfer/http.test.ts b/src/lib/server/storage/transfer/http.test.ts new file mode 100644 index 00000000..093dcda1 --- /dev/null +++ b/src/lib/server/storage/transfer/http.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { createMockLogger } from '$lib/test-utils/mock-logger.js'; +import type { StorageProvider } from '../provider.js'; +import type { TransferRequest } from './resolve.js'; + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); +vi.mock('$lib/server/feature-flags.js', () => ({ + storagePasteEnabled: true, + storageMoveEnabled: true, + storageRenameEnabled: true +})); + +const MINUTE = 60_000; + +const request: TransferRequest = { + op: 'copy', + sourceBucket: 'b', + destBucket: 'b', + destPrefix: 'dest/', + items: [{ sourceKey: 'a.txt' }] +}; + +/** Fresh modules under fake timers, with an engine the test settles itself. */ +async function load() { + vi.resetModules(); + let settle!: { resolve: () => void; reject: (err: Error) => void }; + const engine = new Promise<{ items: []; summary: Record }>((resolve, reject) => { + settle = { + resolve: () => + resolve({ + items: [], + summary: { done: 1, partial: 0, failed: 0, refused: 0, cancelled: 0 } + }), + reject + }; + }); + vi.doMock('./execute.js', () => ({ executeTransfer: () => engine })); + const http = await import('./http.js'); + const jobs = await import('../job-store.js'); + const start = (jobId = randomUUID()) => { + http.streamTransfer({ + request, + jobId, + userId: 'owner', + source: {} as StorageProvider, + dest: {} as StorageProvider, + concurrency: 1, + logger: createMockLogger() + }); + return jobId; + }; + return { start, settle, jobs }; +} + +describe('streamTransfer', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.doUnmock('./execute.js'); + vi.useRealTimers(); + }); + + it('keeps a long transfer alive with a heartbeat, even without progress', async () => { + const { start, settle, jobs } = await load(); + const jobId = start(); + + // A single CopyObject can run longer than the 30-minute expiry without reporting anything. + await vi.advanceTimersByTimeAsync(45 * MINUTE); + expect(jobs.getJob(jobId, 'owner')?.status).toBe('running'); + + settle.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(jobs.getJob(jobId, 'owner')?.status).toBe('done'); + // The heartbeat stops with the transfer, so the finished job expires as usual. + await vi.advanceTimersByTimeAsync(7 * MINUTE); + expect(jobs.getJob(jobId, 'owner')).toBeNull(); + }); + + it('ends the job as error when the engine crashes', async () => { + const { start, settle, jobs } = await load(); + const jobId = start(); + + settle.reject(new Error('bug')); + await vi.advanceTimersByTimeAsync(0); + + expect(jobs.getJob(jobId, 'owner')).toMatchObject({ + status: 'error', + error: 'Transfer crashed' + }); + }); +}); diff --git a/src/lib/server/storage/transfer/http.ts b/src/lib/server/storage/transfer/http.ts new file mode 100644 index 00000000..e4e206fc --- /dev/null +++ b/src/lib/server/storage/transfer/http.ts @@ -0,0 +1,135 @@ +import { error } from '@sveltejs/kit'; +import type pino from 'pino'; +import { + storageMoveEnabled, + storagePasteEnabled, + storageRenameEnabled +} from '$lib/server/feature-flags.js'; +import { storageTransferDuration, storageTransferItemsTotal } from '$lib/server/metrics.js'; +import type { StorageProvider } from '../provider.js'; +import { + createJob, + failJob, + finishJob, + recordJobItem, + setJobCancellation, + touchJob +} from '../job-store.js'; +import { toItemResult, type TransferEvent, type TransferOp } from '$lib/storage/transfer-types.js'; +import { executeTransfer } from './execute.js'; +import { TransferRequestError, validateTransferRequest, type TransferRequest } from './resolve.js'; + +/** A running transfer refreshes its job this often, so no copy expires mid-transfer. */ +const HEARTBEAT_INTERVAL_MS = 60_000; + +/** Whether the feature flags allow `op`: copy needs paste, move needs move or paste, rename needs rename. */ +export function transferAllowed(op: TransferOp): boolean { + if (op === 'copy') return storagePasteEnabled; + if (op === 'move') return storageMoveEnabled || storagePasteEnabled; + return storageRenameEnabled; +} + +/** Throws 404 when the feature flags do not allow `op`. */ +export function requireTransferAllowed(op: TransferOp, logger: pino.Logger, userId?: string): void { + if (transferAllowed(op)) return; + logger.warn({ operation: op, user_id: userId }, 'storage operation rejected: feature disabled'); + throw error(404, `Storage ${op} is disabled`); +} + +/** Validates a request as a whole, mapping its problems to HTTP 400. */ +export function requireValidTransfer(request: TransferRequest): void { + try { + validateTransferRequest(request); + } catch (err) { + if (err instanceof TransferRequestError) throw error(400, err.message); + throw err; + } +} + +interface StreamTransferOptions { + request: TransferRequest; + jobId: string; + userId: string; + source: StorageProvider; + dest: StorageProvider; + concurrency: number; + logger: pino.Logger; +} + +/** + * Start a transfer and answer with its NDJSON events. The transfer runs to its + * end even if the client disconnects; only cancelling its job cancels it. The + * job route is shared by all storage jobs (transfers and downloads) and lives at + * `/api/storage/copy/job/[jobId]` for historical reasons: `DELETE` cancels, `GET` + * reads the job. Every item result is recorded in the job, so a client that lost + * the stream can read them back. Throws 409 if the job ID was used before. + */ +export function streamTransfer(options: StreamTransferOptions): Response { + const { request, jobId, userId, logger } = options; + createJob(jobId, userId, { allowRestart: false, cancelSettles: false }); + + const controller = new AbortController(); + setJobCancellation(jobId, () => controller.abort()); + const heartbeat = setInterval(() => touchJob(jobId), HEARTBEAT_INTERVAL_MS); + heartbeat.unref?.(); + + const encoder = new TextEncoder(); + let streamController!: ReadableStreamDefaultController; + // `start` runs synchronously, so the controller exists before the engine emits. + const stream = new ReadableStream({ + start(c) { + streamController = c; + } + }); + const closeStream = () => { + try { + streamController.close(); + } catch { + // Already closed or the client disconnected. + } + }; + const emit = (event: TransferEvent) => { + if (event.type === 'item-done') { + recordJobItem(jobId, toItemResult(event)); + storageTransferItemsTotal.inc({ op: request.op, status: event.status }); + } + try { + streamController.enqueue(encoder.encode(JSON.stringify(event) + '\n')); + } catch { + // Client disconnected — the transfer continues and is readable from the job. + } + if (event.type === 'complete') closeStream(); + }; + + const stopTimer = storageTransferDuration.startTimer({ op: request.op }); + void executeTransfer(request, { + source: options.source, + dest: options.dest, + concurrency: options.concurrency, + signal: controller.signal, + logger: logger.child({ job_id: jobId }), + emit + }) + .then( + ({ summary }) => finishJob(jobId, { summary }), + (err) => { + // The engine never throws; if it does, the stream ends without `complete` + // and the client reads the recorded items from the job. + logger.error({ err, job_id: jobId }, 'transfer crashed'); + failJob(jobId, 'Transfer crashed'); + closeStream(); + } + ) + .finally(() => { + clearInterval(heartbeat); + stopTimer(); + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + } + }); +} diff --git a/src/lib/server/storage/transfer/memory-provider.test-utils.ts b/src/lib/server/storage/transfer/memory-provider.test-utils.ts new file mode 100644 index 00000000..f5f051ed --- /dev/null +++ b/src/lib/server/storage/transfer/memory-provider.test-utils.ts @@ -0,0 +1,75 @@ +import { error } from '@sveltejs/kit'; +import { vi } from 'vitest'; +import type { CopyObjectOptions, StorageProvider } from '../provider.js'; + +/** Object sizes by key, per bucket. */ +export type MemoryStore = Map>; + +export function memoryStore( + buckets: Record | string[]> +): MemoryStore { + return new Map( + Object.entries(buckets).map(([bucket, objects]) => [ + bucket, + new Map(Array.isArray(objects) ? objects.map((key) => [key, 1]) : Object.entries(objects)) + ]) + ); +} + +function abortError(): Error { + return Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }); +} + +/** + * A bucket-scoped provider over an in-memory store, implementing just what the + * transfer engine uses. Every method is a spy so tests can wrap or inspect it. + */ +export function memoryProvider(store: MemoryStore, bucket: string) { + const objects = () => { + let map = store.get(bucket); + if (!map) store.set(bucket, (map = new Map())); + return map; + }; + const provider = { + exists: vi.fn(async (key: string) => objects().has(key)), + prefixExists: vi.fn(async (prefix: string) => + [...objects().keys()].some((key) => key.startsWith(prefix)) + ), + getMetadata: vi.fn(async (key: string) => { + const size = objects().get(key); + if (size === undefined) throw error(404, `Object "${key}" not found`); + return { size, lastModified: new Date(0) }; + }), + listAllKeysProgressively: vi.fn( + async ( + prefix: string, + onBatch: (batch: Array<{ key: string; size: number }>) => void | boolean, + options?: { signal?: AbortSignal } + ) => { + if (options?.signal?.aborted) throw abortError(); + const batch = [...objects()] + .filter(([key]) => key.startsWith(prefix)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, size]) => ({ key, size })); + if (batch.length > 0) onBatch(batch); + } + ), + copyObject: vi.fn(async (sourceKey: string, destKey: string, options?: CopyObjectOptions) => { + if (options?.signal?.aborted) throw abortError(); + const size = store.get(options?.sourceBucket ?? bucket)?.get(sourceKey); + if (size === undefined) throw error(404, `Object "${sourceKey}" not found`); + objects().set(destKey, size); + }), + deleteObjects: vi.fn(async (keys: string[], expandDirectories: boolean) => { + if (expandDirectories) throw new Error('the engine must never expand directories'); + for (const key of keys) objects().delete(key); + return { failed: [] as Array<{ key: string; code?: string; message?: string }> }; + }) + }; + return provider as typeof provider & StorageProvider; +} + +/** The sorted keys of a bucket. */ +export function keysOf(store: MemoryStore, bucket: string): string[] { + return [...(store.get(bucket)?.keys() ?? [])].sort(); +} diff --git a/src/lib/server/storage/transfer/pool.ts b/src/lib/server/storage/transfer/pool.ts new file mode 100644 index 00000000..1a142ca8 --- /dev/null +++ b/src/lib/server/storage/transfer/pool.ts @@ -0,0 +1,56 @@ +/** + * Run `worker` over `items` with at most `concurrency` calls in flight, in + * input order. Once `signal` is aborted no further call starts; calls already + * running settle on their own. Results keep the input order, and items that + * never started are `undefined`. If a worker throws, the remaining calls still + * run and the first error is rethrown at the end. + */ +export async function runPool( + items: readonly T[], + concurrency: number, + worker: (item: T, index: number) => Promise, + signal?: AbortSignal +): Promise> { + const results: Array = new Array(items.length); + const errors: unknown[] = []; + let next = 0; + + async function lane(): Promise { + while (next < items.length && !signal?.aborted) { + const index = next++; + try { + results[index] = await worker(items[index], index); + } catch (err) { + errors.push(err); + } + } + } + + const lanes = Math.max(1, Math.min(concurrency, items.length)); + await Promise.all(Array.from({ length: lanes }, lane)); + if (errors.length > 0) throw errors[0]; + return results; +} + +/** + * A limiter that runs at most `concurrency` of the calls passed to it at once; + * further calls wait in order. Unlike `runPool` it is shared by callers that + * start work independently, such as the items of one transfer. + */ +export function createLimiter(concurrency: number) { + const waiting: Array<() => void> = []; + let running = 0; + + return async function limit(fn: () => Promise): Promise { + if (running >= concurrency) await new Promise((resolve) => waiting.push(resolve)); + else running++; + try { + return await fn(); + } finally { + // Hand the slot straight to the next waiter, so `running` never dips in between. + const next = waiting.shift(); + if (next) next(); + else running--; + } + }; +} diff --git a/src/lib/server/storage/transfer/resolve.test.ts b/src/lib/server/storage/transfer/resolve.test.ts new file mode 100644 index 00000000..4ed33a7b --- /dev/null +++ b/src/lib/server/storage/transfer/resolve.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest'; +import { + isInside, + resolveItems, + toPlanItem, + validateTransferRequest, + type TransferRequest +} from './resolve.js'; +import { memoryProvider, memoryStore } from './memory-provider.test-utils.js'; +import type { TransferItemRequest } from '$lib/storage/transfer-types.js'; + +function request( + op: TransferRequest['op'], + items: Array, + destPrefix = 'dest/', + buckets: { sourceBucket?: string; destBucket?: string } = {} +): TransferRequest { + return { + op, + sourceBucket: buckets.sourceBucket ?? 'b', + destBucket: buckets.destBucket ?? buckets.sourceBucket ?? 'b', + destPrefix, + items: items.map((item) => (typeof item === 'string' ? { sourceKey: item } : item)) + }; +} + +async function plan(objects: string[], req: TransferRequest) { + const dest = memoryProvider(memoryStore({ [req.destBucket]: objects }), req.destBucket); + return (await resolveItems(req, dest, { mode: 'plan', concurrency: 4 })).map(toPlanItem); +} + +async function resolve(objects: string[], req: TransferRequest) { + const dest = memoryProvider(memoryStore({ [req.destBucket]: objects }), req.destBucket); + return resolveItems(req, dest, { mode: 'execute', concurrency: 4 }); +} + +describe('isInside', () => { + it('applies only to folders', () => { + expect(isInside('a/b.txt', 'a/')).toBe(true); + expect(isInside('a/', 'a/')).toBe(true); + expect(isInside('ab/c', 'a')).toBe(false); + expect(isInside('a/b', 'a')).toBe(false); + expect(isInside('ab/', 'a/')).toBe(false); + }); +}); + +describe('validateTransferRequest', () => { + it('accepts up to 1000 items and refuses more', () => { + const keys = Array.from({ length: 1001 }, (_, i) => `f${i}`); + expect(() => validateTransferRequest(request('copy', keys.slice(0, 1000)))).not.toThrow(); + expect(() => validateTransferRequest(request('copy', keys))).toThrow('At most 1000'); + }); + + it('refuses empty, duplicate and overlapping sources', () => { + expect(() => validateTransferRequest(request('copy', []))).toThrow(); + expect(() => validateTransferRequest(request('copy', ['a', 'a']))).toThrow('Duplicate'); + expect(() => validateTransferRequest(request('copy', ['a/', 'a/b.txt']))).toThrow( + 'Overlapping' + ); + expect(() => validateTransferRequest(request('copy', ['a', 'a/']))).not.toThrow(); + }); + + it('keeps a rename in its bucket', () => { + const req = request('rename', [{ sourceKey: 'a', name: 'b' }], '', { + sourceBucket: 'x', + destBucket: 'y' + }); + expect(() => validateTransferRequest(req)).toThrow('bucket'); + }); +}); + +describe('folder existence', () => { + it.each([ + ['marker only', ['dest/docs/']], + ['marker and children', ['dest/docs/', 'dest/docs/a.txt']], + ['children only', ['dest/docs/nested/a.txt']] + ])('reports a folder conflict for %s', async (_, objects) => { + const [item] = await plan(objects, request('copy', ['src/docs/'])); + expect(item.conflict).toBe('folder'); + }); + + it('never lets a file and a folder of the same name conflict', async () => { + const items = await plan(['dest/docs', 'dest/a/'], request('copy', ['src/docs/', 'src/a'])); + expect(items.map((item) => item.conflict)).toEqual(['none', 'none']); + }); +}); + +describe('plan', () => { + it('marks a copy onto itself and suggests the first free numbered name', async () => { + const [item] = await plan(['dest/a.txt', 'dest/a (1).txt'], request('copy', ['dest/a.txt'])); + expect(item).toEqual({ + sourceKey: 'dest/a.txt', + kind: 'file', + destKey: 'dest/a.txt', + conflict: 'file', + selfConflict: true, + suggestedName: 'a (2).txt' + }); + }); + + it('lets earlier items count when suggesting names', async () => { + const items = await plan(['dest/a.txt'], request('copy', ['dest/a.txt', 'other/a.txt'])); + expect(items[0].suggestedName).toBe('a (1).txt'); + expect(items[1]).toMatchObject({ conflict: 'file', suggestedName: 'a (2).txt' }); + }); + + it('does not refuse an item whose name another item only got suggested', async () => { + const items = await plan(['dest/a.txt'], request('copy', ['p/a.txt', 'q/a (1).txt'])); + expect(items[1]).toMatchObject({ conflict: 'none' }); + expect(items[1].refused).toBeUndefined(); + }); + + it('refuses a second item with the same destination', async () => { + const items = await plan([], request('copy', ['p/docs/', 'q/docs/'])); + expect(items[0].refused).toBeUndefined(); + expect(items[1].refused).toBe('duplicate_destination'); + }); +}); + +describe('refusals', () => { + it('refuses a move to where the item already is', async () => { + const [item] = await resolve(['dest/a.txt'], request('move', ['dest/a.txt'])); + expect(item.refused).toBe('same_location'); + }); + + it('refuses a copy onto itself without keepBoth or rename', async () => { + const [plain] = await resolve(['dest/a.txt'], request('copy', ['dest/a.txt'])); + const [replace] = await resolve( + ['dest/a.txt'], + request('copy', [{ sourceKey: 'dest/a.txt', policy: 'replace' }]) + ); + const [keepBoth] = await resolve( + ['dest/a.txt'], + request('copy', [{ sourceKey: 'dest/a.txt', policy: 'keepBoth' }]) + ); + expect(plain.refused).toBe('same_location'); + expect(replace.refused).toBe('same_location'); + expect(keepBoth.refused).toBeUndefined(); + expect(keepBoth.destKey).toBe('dest/a (1).txt'); + }); + + it('refuses a folder into its own subtree', async () => { + const items = await resolve(['a/'], request('move', ['a/'], 'a/b/')); + expect(items[0].refused).toBe('into_itself'); + }); + + it('only checks into_itself within one bucket', async () => { + const req = request('copy', ['a/'], 'a/b/', { sourceBucket: 'x', destBucket: 'y' }); + const [item] = await resolve([], req); + expect(item.refused).toBeUndefined(); + }); + + it('refuses a destination that equals, contains or is inside another source', async () => { + const equals = await resolve([], request('copy', ['x/a.txt', 'dest/a.txt'], 'dest/')); + expect(equals[0].refused).toBe('overlaps_source'); + const contains = await resolve([], request('move', ['x/sub/', 'dest/sub/a.txt'], 'dest/')); + expect(contains[0].refused).toBe('overlaps_source'); + const inside = await resolve([], request('copy', ['a.txt', 'dest/'], 'dest/')); + expect(inside.map((item) => item.refused)).toEqual(['overlaps_source', 'into_itself']); + }); + + it('refuses a folder into an ancestor that contains it, whatever the policy', async () => { + const objects = ['y/y/f', 'y/y/y/f']; + for (const op of ['move', 'copy'] as const) { + const [merge] = await resolve( + objects, + request(op, [{ sourceKey: 'y/y/', policy: 'merge' }], '') + ); + expect(merge).toMatchObject({ destKey: 'y/', refused: 'overlaps_source' }); + } + const [planned] = await plan(objects, request('move', ['y/y/'], '')); + expect(planned.refused).toBe('overlaps_source'); + }); + + it('allows a keepBoth copy next to the ancestor, which no longer contains the source', async () => { + const [item] = await resolve( + ['y/y/f'], + request('copy', [{ sourceKey: 'y/y/', policy: 'keepBoth' }], '') + ); + expect(item.refused).toBeUndefined(); + expect(item.destKey).toBe('y (1)/'); + }); + + it('allows moving a folder up when the destination does not contain it', async () => { + const [item] = await resolve(['a/b/f'], request('move', ['a/b/'], '')); + expect(item).toMatchObject({ destKey: 'b/', conflict: 'none' }); + expect(item.refused).toBeUndefined(); + }); + + it('refuses a conflict that appeared after the plan', async () => { + const [item] = await resolve(['dest/a.txt'], request('copy', ['src/a.txt'])); + expect(item.refused).toBe('conflict'); + }); + + it('applies replace to files and merge to folders only', async () => { + const items = await resolve( + ['dest/a.txt', 'dest/docs/x'], + request('copy', [ + { sourceKey: 'src/a.txt', policy: 'replace' }, + { sourceKey: 'src/docs/', policy: 'merge' }, + { sourceKey: 'src/b.txt', policy: 'merge' }, + { sourceKey: 'src/more/', policy: 'replace' } + ]) + ); + expect(items.map((item) => item.refused)).toEqual([ + undefined, + undefined, + 'policy_mismatch', + 'policy_mismatch' + ]); + }); + + it('refuses invalid rename names', async () => { + const items = await resolve( + [], + request('copy', [ + { sourceKey: 'src/a.txt', policy: 'rename', name: 'x/y' }, + { sourceKey: 'src/b.txt', policy: 'rename' }, + { sourceKey: 'src/c.txt', policy: 'rename', name: '..' } + ]) + ); + expect(items.map((item) => item.refused)).toEqual([ + 'invalid_name', + 'invalid_name', + 'invalid_name' + ]); + }); + + it('never lets a rename overwrite', async () => { + const [item] = await resolve( + ['a/b.txt', 'a/c.txt'], + request('rename', [{ sourceKey: 'a/b.txt', name: 'c.txt' }]) + ); + expect(item).toMatchObject({ destKey: 'a/c.txt', refused: 'conflict' }); + }); + + it('renames within the parent, keeping empty path segments', async () => { + const [item] = await resolve( + ['a/c.txt', 'a//b.txt'], + request('rename', [{ sourceKey: 'a//b.txt', name: 'c.txt' }]) + ); + expect(item.refused).toBeUndefined(); + expect(item.destKey).toBe('a//c.txt'); + }); + + it('refuses keys over 1024 bytes, including numbered names', async () => { + const long = 'x'.repeat(1020); + const [tooLong] = await resolve([], request('copy', [`src/${long}.txt`])); + expect(tooLong.refused).toBe('name_too_long'); + const fits = `${'y'.repeat(1015)}.txt`; + const [numbered] = await resolve( + [`dest/${fits}`], + request('copy', [{ sourceKey: `src/${fits}`, policy: 'keepBoth' }]) + ); + expect(numbered.refused).toBe('name_too_long'); + }); +}); + +describe('keepBoth', () => { + it('skips names taken earlier in the same request', async () => { + const items = await resolve( + ['dest/a.txt'], + request('copy', [ + { sourceKey: 'p/a.txt', policy: 'keepBoth' }, + { sourceKey: 'q/a.txt', policy: 'keepBoth' } + ]) + ); + expect(items.map((item) => item.destKey)).toEqual(['dest/a (1).txt', 'dest/a (2).txt']); + }); + + it('numbers an item whose name an earlier item took', async () => { + const items = await resolve( + [], + request('copy', ['p/a.txt', { sourceKey: 'q/a.txt', policy: 'keepBoth' }]) + ); + expect(items.map((item) => item.destKey)).toEqual(['dest/a.txt', 'dest/a (1).txt']); + }); + + it('numbers folders at the end of the name', async () => { + const [item] = await resolve( + ['dest/v1.2/x'], + request('copy', [{ sourceKey: 'src/v1.2/', policy: 'keepBoth' }]) + ); + expect(item.destKey).toBe('dest/v1.2 (1)/'); + }); +}); diff --git a/src/lib/server/storage/transfer/resolve.ts b/src/lib/server/storage/transfer/resolve.ts new file mode 100644 index 00000000..0575ce0b --- /dev/null +++ b/src/lib/server/storage/transfer/resolve.ts @@ -0,0 +1,269 @@ +import type { StorageProvider } from '../provider.js'; +import { numberedName } from '$lib/storage/numbered-name.js'; +import { ConflictRenameNameSchema, StorageObjectKeySchema } from '$lib/storage/schemas.js'; +import { keyToName, parentPrefix } from '$lib/storage/utils.js'; +import { + MAX_TRANSFER_ITEMS, + type PlanItem, + type RefusalReason, + type TransferItemKind, + type TransferItemRequest, + type TransferOp +} from '$lib/storage/transfer-types.js'; +import { runPool } from './pool.js'; + +/** A transfer within one storage connection. */ +export interface TransferRequest { + op: TransferOp; + sourceBucket: string; + /** Equal to `sourceBucket` for a rename. */ + destBucket: string; + /** The folder copied or moved into; unused for a rename, which keeps each item's folder. */ + destPrefix: string; + items: TransferItemRequest[]; +} + +/** A malformed request as a whole; answered with HTTP 400. */ +export class TransferRequestError extends Error {} + +export interface ResolvedItem { + sourceKey: string; + kind: TransferItemKind; + /** Where the item lands by its name, before conflict handling. */ + baseDestKey: string; + /** Where the item is written; differs from `baseDestKey` for `keepBoth`. */ + destKey: string; + conflict: 'none' | TransferItemKind; + selfConflict: boolean; + refused?: RefusalReason; + /** Execute only: the existence check failed, so the item cannot run. A plan throws instead. */ + error?: string; + /** Plan only: first free numbered key, when conflicting. */ + suggestedKey?: string; +} + +interface ResolveOptions { + /** `plan` reports conflicts; `execute` applies each item's policy to them. */ + mode: 'plan' | 'execute'; + concurrency: number; + signal?: AbortSignal; +} + +/** `x` is inside `folder` when `folder` is a folder key and `x` starts with it. */ +export function isInside(x: string, folder: string): boolean { + return folder.endsWith('/') && x.startsWith(folder); +} + +/** + * Refuse requests that cannot be planned item by item: too many items, + * duplicate sources, or one source inside another. + */ +export function validateTransferRequest(request: TransferRequest): void { + const keys = request.items.map((item) => item.sourceKey); + if (keys.length === 0) throw new TransferRequestError('No items'); + if (keys.length > MAX_TRANSFER_ITEMS) { + throw new TransferRequestError(`At most ${MAX_TRANSFER_ITEMS} items per request`); + } + if (new Set(keys).size !== keys.length) throw new TransferRequestError('Duplicate source keys'); + const folders = keys.filter((key) => key.endsWith('/')); + for (const key of keys) { + if (folders.some((folder) => folder !== key && isInside(key, folder))) { + throw new TransferRequestError('Overlapping source keys'); + } + } + if (request.op === 'rename' && request.destBucket !== request.sourceBucket) { + throw new TransferRequestError('A rename stays in its bucket'); + } +} + +/** The message of a provider error, which is usually a SvelteKit HttpError. */ +export function transferErrorMessage(err: unknown): string { + if (err && typeof err === 'object' && 'body' in err) { + const body = (err as { body?: { message?: unknown } }).body; + if (typeof body?.message === 'string') return body.message; + } + return err instanceof Error ? err.message : 'Unknown error'; +} + +/** File `a` and folder `a/` are different keys, so each kind has its own check. */ +function destinationExists(dest: StorageProvider, key: string, signal?: AbortSignal) { + return key.endsWith('/') ? dest.prefixExists(key, signal) : dest.exists(key); +} + +/** Whether `key` exceeds the S3 limit of 1024 bytes of UTF-8. */ +export function isTooLong(key: string): boolean { + return !StorageObjectKeySchema.safeParse(key).success; +} + +/** The item before any existence check: its destination by name and the refusals that need none. */ +function resolveStatic(request: TransferRequest, item: TransferItemRequest): ResolvedItem { + const { sourceKey, policy, name } = item; + const kind: TransferItemKind = sourceKey.endsWith('/') ? 'folder' : 'file'; + const suffix = kind === 'folder' ? '/' : ''; + const renamed = request.op === 'rename' || policy === 'rename'; + const parent = request.op === 'rename' ? parentPrefix(sourceKey) : request.destPrefix; + const baseDestKey = parent + (renamed ? (name ?? '') : keyToName(sourceKey)) + suffix; + const resolved: ResolvedItem = { + sourceKey, + kind, + baseDestKey, + destKey: baseDestKey, + conflict: 'none', + selfConflict: false + }; + + const sameBucket = request.sourceBucket === request.destBucket; + const refuse = (reason: RefusalReason) => ({ ...resolved, refused: reason }); + + if (renamed && !ConflictRenameNameSchema.safeParse(name ?? '').success) { + return refuse('invalid_name'); + } + if (isTooLong(baseDestKey)) return refuse('name_too_long'); + if ((policy === 'replace' && kind !== 'file') || (policy === 'merge' && kind !== 'folder')) { + return refuse('policy_mismatch'); + } + if (sameBucket && baseDestKey === sourceKey) { + // Only a copy can keep both; a move or rename onto itself does nothing. + if (request.op !== 'copy') return refuse('same_location'); + return { ...resolved, selfConflict: true }; + } + if (sameBucket && kind === 'folder' && isInside(baseDestKey, sourceKey)) { + return refuse('into_itself'); + } + return resolved; +} + +/** + * Resolve every item of a request to its destination, in request order. + * Existence checks run through the concurrency pool first; duplicate + * destinations and numbered names are then decided in order, so earlier + * items count when later ones pick a name. + */ +export async function resolveItems( + request: TransferRequest, + dest: StorageProvider, + options: ResolveOptions +): Promise { + const { mode, concurrency, signal } = options; + const items = request.items.map((item) => resolveStatic(request, item)); + + const exists = await runPool( + items, + concurrency, + async (item) => { + if (item.refused) return false; + if (item.selfConflict) return true; + try { + return await destinationExists(dest, item.baseDestKey, signal); + } catch (err) { + if (mode === 'plan') throw err; + item.error = transferErrorMessage(err); + return false; + } + }, + signal + ); + + const sameBucket = request.sourceBucket === request.destBucket; + const sources = request.items.map((item) => item.sourceKey); + const claimed = new Set(); + + /** The first numbered key that exists neither in storage nor earlier in this request. */ + async function firstFree(base: string): Promise { + for (let counter = 1; ; counter++) { + const candidate = numberedName(base, counter); + if (isTooLong(candidate)) return null; + if (claimed.has(candidate)) continue; + if (!(await destinationExists(dest, candidate, signal))) return candidate; + } + } + + /** + * Whether `destKey` equals, contains or is inside another source, or contains + * the item's own source (a folder copied into one of its ancestors): writing + * there would overwrite source objects, which a move then deletes. + */ + function overlapsSource(destKey: string, index: number): boolean { + if (!sameBucket) return false; + return sources.some((source, j) => + j === index + ? source !== destKey && isInside(source, destKey) + : source === destKey || isInside(destKey, source) || isInside(source, destKey) + ); + } + + for (const [index, item] of items.entries()) { + if (item.refused || item.error || exists[index] === undefined) continue; + if (exists[index]) item.conflict = item.kind; + const policy = request.items[index].policy; + + try { + if (mode === 'plan') { + if (claimed.has(item.baseDestKey)) { + item.refused = 'duplicate_destination'; + continue; + } + if (item.conflict !== 'none') { + const suggested = await firstFree(item.baseDestKey); + if (suggested) item.suggestedKey = suggested; + // A self-conflicting copy is always kept as both, so it takes the numbered name. + if (item.selfConflict) { + if (!suggested) { + item.refused = 'name_too_long'; + continue; + } + item.destKey = suggested; + } + } + } else if (policy === 'keepBoth' && (item.conflict !== 'none' || claimed.has(item.destKey))) { + const free = await firstFree(item.baseDestKey); + if (!free) { + item.refused = 'name_too_long'; + continue; + } + item.destKey = free; + } else if (claimed.has(item.destKey)) { + item.refused = 'duplicate_destination'; + continue; + } else if (item.conflict !== 'none') { + // `replace` and `merge` are the only policies that may write onto an existing key; + // without a policy the conflict appeared after the plan, and a rename never overwrites. + const allowed = + request.op !== 'rename' && + (policy === 'replace' || policy === 'merge') && + !item.selfConflict; + if (!allowed) { + item.refused = item.selfConflict ? 'same_location' : 'conflict'; + continue; + } + } + } catch (err) { + if (mode === 'plan') throw err; + item.error = transferErrorMessage(err); + continue; + } + + if (overlapsSource(item.destKey, index)) { + item.refused = 'overlaps_source'; + continue; + } + // A suggestion is not claimed: the user may still pick replace or merge, and a + // later item that really lands on that name must not be refused for it. + claimed.add(item.destKey); + } + + return items; +} + +/** The plan response item for a resolved item. */ +export function toPlanItem(item: ResolvedItem): PlanItem { + return { + sourceKey: item.sourceKey, + kind: item.kind, + destKey: item.baseDestKey, + conflict: item.conflict, + ...(item.selfConflict ? { selfConflict: true as const } : {}), + ...(item.refused ? { refused: item.refused } : {}), + ...(item.suggestedKey ? { suggestedName: keyToName(item.suggestedKey) } : {}) + }; +} diff --git a/src/lib/server/storage/types.ts b/src/lib/server/storage/types.ts index fc3f71f9..daf69405 100644 --- a/src/lib/server/storage/types.ts +++ b/src/lib/server/storage/types.ts @@ -7,6 +7,7 @@ export interface S3ConnectionConfig { accessStyle: 'Path' | 'VirtualHosted'; region: { name: string }; credentials?: { accessKey: string; secretKey: string }; + additionalBuckets?: string[]; } /** Full S3 config for creating a bucket-scoped provider. */ @@ -26,3 +27,5 @@ export type StorageConfig = S3Config | HDFSConfig; /** Per-user connection config stored in memory (no bucket). */ export type StorageConnectionConfig = S3ConnectionConfig | HDFSConfig; + +export type { ConnectionMetadata } from '$lib/storage/connection-types.js'; diff --git a/src/lib/server/storage/utils.test.ts b/src/lib/server/storage/utils.test.ts index 013e99e7..9bde1433 100644 --- a/src/lib/server/storage/utils.test.ts +++ b/src/lib/server/storage/utils.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -vi.mock('$lib/server/logging', () => ({ - logger: { child: () => ({ info: vi.fn(), debug: vi.fn() }) } -})); +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); const mockCreate = vi.fn().mockReturnValue({ listObjects: vi.fn() }); vi.mock('./factory.js', () => ({ diff --git a/src/lib/server/storage/utils.ts b/src/lib/server/storage/utils.ts index a0ab2223..4c3f5768 100644 --- a/src/lib/server/storage/utils.ts +++ b/src/lib/server/storage/utils.ts @@ -1,16 +1,32 @@ import { error } from '@sveltejs/kit'; import { StorageProviderFactory } from './factory.js'; import type { StorageProvider } from './provider.js'; -import type { S3ConnectionConfig } from './types.js'; +import type { S3ConnectionConfig, StorageConfig } from './types.js'; /** * Construct a bucket-scoped storage provider from the given connection config. * Throws a 400 HTTP error if the connection type is not supported. */ -export function getProvider(config: S3ConnectionConfig, bucket: string): StorageProvider { +export function getProvider( + config: StorageConfig | S3ConnectionConfig, + bucket: string +): StorageProvider { if (config.type !== 's3') { throw error(400, 'Storage backend not supported'); } return StorageProviderFactory.create({ ...config, bucket }); } + +/** + * Construct a connection-scoped storage provider for operations that do not + * require a specific bucket (e.g. listing all buckets). + * Throws a 400 HTTP error if the connection type is not supported. + */ +export function getConnectionProvider(config: StorageConfig | S3ConnectionConfig): StorageProvider { + if (config.type !== 's3') { + throw error(400, 'Storage backend not supported'); + } + + return StorageProviderFactory.create({ ...config, bucket: '' }); +} diff --git a/src/lib/server/storage/wrap-provider.ts b/src/lib/server/storage/wrap-provider.ts new file mode 100644 index 00000000..a6d55eac --- /dev/null +++ b/src/lib/server/storage/wrap-provider.ts @@ -0,0 +1,29 @@ +import { error, isHttpError } from '@sveltejs/kit'; +import type { StorageProvider } from './provider.js'; + +/** Maps provider failures to consistent HTTP responses for request handlers. */ +export function withStorageHttpErrors(raw: StorageProvider): StorageProvider { + return new Proxy(raw, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof value === 'function') { + return (...args: unknown[]) => { + try { + const result = value.apply(target, args); + if (result instanceof Promise) { + return result.catch((err: unknown) => { + if (isHttpError(err)) throw err; + throw error(502, err instanceof Error ? err.message : 'Storage provider error'); + }); + } + return result; + } catch (err) { + if (isHttpError(err)) throw err; + throw error(502, err instanceof Error ? err.message : 'Storage provider error'); + } + }; + } + return value; + } + }); +} diff --git a/src/lib/server/storage/zip-stream.test.ts b/src/lib/server/storage/zip-stream.test.ts new file mode 100644 index 00000000..882e2c74 --- /dev/null +++ b/src/lib/server/storage/zip-stream.test.ts @@ -0,0 +1,155 @@ +import AdmZip from 'adm-zip'; +import { describe, expect, it, vi } from 'vitest'; +import { createZipStream, zipStreamSize } from './zip-stream.js'; +import type { StorageProvider } from './provider.js'; + +function objectStream(value: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(value)); + controller.close(); + } + }); +} + +describe('createZipStream', () => { + it('reads objects only on demand and releases the provider on cancellation', async () => { + const cancel = vi.fn(); + const pull = vi.fn((controller: ReadableStreamDefaultController) => + controller.enqueue(new Uint8Array([1])) + ); + const object = new ReadableStream({ pull, cancel }, { highWaterMark: 0 }); + const getObject = vi.fn(async () => ({ stream: object })); + const reader = createZipStream( + { getObject } as unknown as StorageProvider, + [{ key: 'large.bin', size: 100_000, isDirectory: false }], + '' + ).getReader(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getObject).not.toHaveBeenCalled(); + await reader.read(); // Local header. + expect(pull).not.toHaveBeenCalled(); + await reader.read(); // First object chunk. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(pull).toHaveBeenCalledTimes(1); + await reader.cancel(); + expect(cancel).toHaveBeenCalledOnce(); + expect(object.locked).toBe(false); + }); + it('calculates the exact length of the generated stream', async () => { + const entries = [{ key: 'file.txt', size: 3, isDirectory: false }]; + const provider = { + getObject: async () => ({ stream: new Response('abc').body! }) + } as unknown as StorageProvider; + + const stream = createZipStream(provider, entries, ''); + expect(zipStreamSize(entries, '')).toBe((await new Response(stream).arrayBuffer()).byteLength); + }); + it('streams readable, uncompressed entries without staging object contents', async () => { + const objects = new Map([ + ['reports/one.txt', 'first report'], + ['reports/two.txt', 'second report'] + ]); + const provider = { + getObject: async (key: string) => ({ stream: objectStream(objects.get(key) ?? '') }) + } as StorageProvider; + + const archive = new Uint8Array( + await new Response( + createZipStream( + provider, + [ + { key: 'reports/', size: 0, isDirectory: true }, + { key: 'reports/one.txt', size: 12, isDirectory: false }, + { key: 'reports/two.txt', size: 13, isDirectory: false } + ], + '' + ) + ).arrayBuffer() + ); + const zip = new AdmZip(Buffer.from(archive)); + + expect(zip.getEntry('reports/')?.isDirectory).toBe(true); + expect(zip.readAsText('reports/one.txt')).toBe('first report'); + expect(zip.readAsText('reports/two.txt')).toBe('second report'); + expect(zip.getEntry('reports/one.txt')?.header.method).toBe(0); + }); + + it('counts a single ZIP64 extra block for entries with large size and offset', () => { + const size = 0xffffffff; + const entries = [ + { key: 'a', size, isDirectory: false }, + { key: 'b', size, isDirectory: false } + ]; + // Per entry: local header 30 + 1 + 20, data, descriptor 24. + const dataEnd = 2 * (51 + size + 24); + // Central headers 46 + 1 + extra: sizes only (4 + 16), then sizes and offset (4 + 16 + 8). + const centralDirectory = 46 + 1 + 20 + (46 + 1 + 28); + // ZIP64 end record, locator and classic end record. + expect(zipStreamSize(entries, '')).toBe(dataEnd + centralDirectory + 98); + }); + it('uses ZIP64 records when the central directory has 65,535 entries', async () => { + const provider = {} as StorageProvider; + const entries = Array.from({ length: 65_535 }, (_, index) => ({ + key: `empty-${index}/`, + size: 0, + isDirectory: true + })); + const archive = new Uint8Array( + await new Response(createZipStream(provider, entries, '')).arrayBuffer() + ); + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); + const eocdOffset = archive.length - 22; + + expect(view.getUint32(eocdOffset, true)).toBe(0x06054b50); + expect(view.getUint16(eocdOffset + 8, true)).toBe(0xffff); + expect(view.getUint32(eocdOffset - 20, true)).toBe(0x07064b50); + }, 60_000); + + it('names entries relative to the download prefix', async () => { + const provider = { + getObject: async () => ({ stream: objectStream('abc'), contentLength: 3 }) + } as unknown as StorageProvider; + const entries = [ + { key: 'data/reports/', size: 0, isDirectory: true }, + { key: 'data/reports/one.txt', size: 3, isDirectory: false } + ]; + const archive = await new Response(createZipStream(provider, entries, 'data/')).arrayBuffer(); + const zip = new AdmZip(Buffer.from(archive)); + + expect(zip.getEntries().map((entry) => entry.entryName)).toEqual([ + 'reports/', + 'reports/one.txt' + ]); + expect(zipStreamSize(entries, 'data/')).toBe(archive.byteLength); + }); + + it('never emits entry names that resolve outside the extraction directory', async () => { + const provider = { + getObject: async () => ({ stream: objectStream('x'), contentLength: 1 }) + } as unknown as StorageProvider; + const entries = [ + { key: '../', size: 0, isDirectory: true }, + { key: '../../.bashrc', size: 1, isDirectory: false }, + { key: '/etc/profile', size: 1, isDirectory: false }, + { key: '..\\..\\evil.txt', size: 1, isDirectory: false }, + { key: 'C:/Windows/evil.txt', size: 1, isDirectory: false }, + { key: 'a/./b/../c.txt', size: 1, isDirectory: false }, + { key: 'c.txt', size: 1, isDirectory: false }, + { key: 'a/b/c.txt', size: 1, isDirectory: false } + ]; + const archive = await new Response(createZipStream(provider, entries, '')).arrayBuffer(); + const names = new AdmZip(Buffer.from(archive)).getEntries().map((entry) => entry.entryName); + + expect(names).toEqual([ + '.bashrc', + 'etc/profile', + '.._.._evil.txt', + 'C_/Windows/evil.txt', + 'a/b/c.txt', + 'c.txt', + 'a/b/c (2).txt' + ]); + expect(zipStreamSize(entries, '')).toBe(archive.byteLength); + }); +}); diff --git a/src/lib/server/storage/zip-stream.ts b/src/lib/server/storage/zip-stream.ts new file mode 100644 index 00000000..ede80d37 --- /dev/null +++ b/src/lib/server/storage/zip-stream.ts @@ -0,0 +1,364 @@ +import { numberedName } from '$lib/storage/numbered-name.js'; +import type { StorageProvider } from './provider.js'; + +const ZIP_VERSION = 20; +const ZIP64_VERSION = 45; +const DATA_DESCRIPTOR_FLAG = 0x08; +const UTF8_FLAG = 0x0800; +const STORE_METHOD = 0; +const UINT16_MAX = 0xffff; +const UINT32_MAX = 0xffffffff; +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_value, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) { + value = value & 1 ? (value >>> 1) ^ 0xedb88320 : value >>> 1; + } + return value >>> 0; +}); + +export interface ZipEntry { + key: string; + size: number; + isDirectory: boolean; +} + +/** + * Calculate the exact byte length of an uncompressed ZIP for known entries. + * Record lengths come from the same builders the stream uses, so the + * advertised Content-Length cannot drift from the generated bytes. + */ +export function zipStreamSize(entries: ZipEntry[], prefix: string): number { + let offset = 0; + let centralDirectorySize = 0; + const layout = archiveLayout(entries, prefix); + + for (const { entry, name } of layout) { + const localOffset = offset; + const zip64 = entry.size >= UINT32_MAX; + offset += localHeader(name, zip64).length; + offset += entry.size + dataDescriptor(0, entry.size, zip64).length; + centralDirectorySize += centralHeader({ + name, + crc32: 0, + size: entry.size, + offset: localOffset, + zip64, + isDirectory: entry.isDirectory + }).length; + } + + return ( + offset + + centralDirectorySize + + endOfCentralDirectory(layout.length, centralDirectorySize, offset).length + ); +} + +interface CentralDirectoryEntry { + name: Uint8Array; + crc32: number; + size: number; + offset: number; + zip64: boolean; + isDirectory: boolean; +} + +function bytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function concat(...chunks: Uint8Array[]): Uint8Array { + const result = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + +function uint16(value: number): Uint8Array { + const result = new Uint8Array(2); + new DataView(result.buffer).setUint16(0, value, true); + return result; +} + +function uint32(value: number): Uint8Array { + const result = new Uint8Array(4); + new DataView(result.buffer).setUint32(0, value, true); + return result; +} + +function uint64(value: number): Uint8Array { + const result = new Uint8Array(8); + const view = new DataView(result.buffer); + const bigint = BigInt(value); + view.setUint32(0, Number(bigint & BigInt(UINT32_MAX)), true); + view.setUint32(4, Number(bigint >> BigInt(32)), true); + return result; +} + +function crc32Update(crc: number, chunk: Uint8Array): number { + let result = crc ^ UINT32_MAX; + for (const byte of chunk) { + result = CRC32_TABLE[(result ^ byte) & 0xff]! ^ (result >>> 8); + } + return (result ^ UINT32_MAX) >>> 0; +} + +function localHeader(name: Uint8Array, zip64: boolean): Uint8Array { + const extra = zip64 ? concat(uint16(0x0001), uint16(16), uint64(0), uint64(0)) : new Uint8Array(); + return concat( + uint32(0x04034b50), + uint16(zip64 ? ZIP64_VERSION : ZIP_VERSION), + uint16(DATA_DESCRIPTOR_FLAG | UTF8_FLAG), + uint16(STORE_METHOD), + uint16(0), + uint16(0), + uint32(0), + uint32(zip64 ? UINT32_MAX : 0), + uint32(zip64 ? UINT32_MAX : 0), + uint16(name.length), + uint16(extra.length), + name, + extra + ); +} + +function dataDescriptor(crc32: number, size: number, zip64: boolean): Uint8Array { + return concat( + uint32(0x08074b50), + uint32(crc32), + zip64 ? uint64(size) : uint32(size), + zip64 ? uint64(size) : uint32(size) + ); +} + +function centralHeader(entry: CentralDirectoryEntry): Uint8Array { + const zip64Size = entry.size >= UINT32_MAX; + const zip64Offset = entry.offset >= UINT32_MAX; + const extraValues = [ + ...(zip64Size ? [uint64(entry.size), uint64(entry.size)] : []), + ...(zip64Offset ? [uint64(entry.offset)] : []) + ]; + const extra = extraValues.length + ? concat( + uint16(0x0001), + uint16(extraValues.reduce((size, value) => size + value.length, 0)), + ...extraValues + ) + : new Uint8Array(); + return concat( + uint32(0x02014b50), + uint16(extra.length ? ZIP64_VERSION : ZIP_VERSION), + uint16(entry.zip64 ? ZIP64_VERSION : ZIP_VERSION), + uint16(DATA_DESCRIPTOR_FLAG | UTF8_FLAG), + uint16(STORE_METHOD), + uint16(0), + uint16(0), + uint32(entry.crc32), + uint32(zip64Size ? UINT32_MAX : entry.size), + uint32(zip64Size ? UINT32_MAX : entry.size), + uint16(entry.name.length), + uint16(extra.length), + uint16(0), + uint16(0), + uint16(0), + uint32(entry.isDirectory ? 0x10 : 0), + uint32(zip64Offset ? UINT32_MAX : entry.offset), + entry.name, + extra + ); +} + +function endOfCentralDirectory(count: number, size: number, offset: number): Uint8Array { + const zip64 = count >= UINT16_MAX || size >= UINT32_MAX || offset >= UINT32_MAX; + if (!zip64) { + return concat( + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(count), + uint16(count), + uint32(size), + uint32(offset), + uint16(0) + ); + } + const zip64Offset = offset + size; + return concat( + uint32(0x06064b50), + uint64(44), + uint16(ZIP64_VERSION), + uint16(ZIP64_VERSION), + uint32(0), + uint32(0), + uint64(count), + uint64(count), + uint64(size), + uint64(offset), + uint32(0x07064b50), + uint32(0), + uint64(zip64Offset), + uint32(1), + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(UINT16_MAX), + uint16(UINT16_MAX), + uint32(UINT32_MAX), + uint32(UINT32_MAX), + uint16(0) + ); +} + +/** + * Object keys are untrusted: anyone with write access to the bucket can create + * keys such as `../../.bashrc` or `/etc/profile`. Names are made relative to + * the folder the download started from and reduced to plain path segments so + * that no extractor can resolve them outside its target directory. + */ +function sanitiseEntryName(key: string, prefix: string): string { + const base = prefix && !prefix.endsWith('/') ? `${prefix}/` : prefix; + const relative = base && key.startsWith(base) ? key.slice(base.length) : key; + const segments = relative + .split('/') + .map((segment) => segment.replaceAll('\\', '_')) + .filter((segment) => segment !== '' && segment !== '.' && segment !== '..'); + // A leading drive letter (`C:`) is treated as an absolute path on Windows. + if (segments.length > 0 && /^[a-z]:/i.test(segments[0])) { + segments[0] = `${segments[0][0]}_${segments[0].slice(2)}`; + } + return segments.join('/'); +} + +function uniqueName(name: string, used: Set): string { + if (!used.has(name)) return name; + for (let index = 2; ; index++) { + const candidate = numberedName(name, index); + if (!used.has(candidate)) return candidate; + } +} + +/** + * Resolve the archive name of every entry. Directories that collapse to an + * empty or already-present name are omitted; files that collide after + * sanitising get a numeric suffix so that no entry overwrites another. + */ +function archiveLayout( + entries: ZipEntry[], + prefix: string +): Array<{ entry: ZipEntry; name: Uint8Array }> { + const used = new Set(); + const layout: Array<{ entry: ZipEntry; name: Uint8Array }> = []; + for (const entry of entries) { + const sanitised = sanitiseEntryName(entry.key, prefix); + let name: string; + if (entry.isDirectory) { + if (!sanitised || used.has(`${sanitised}/`)) continue; + name = `${sanitised}/`; + } else { + name = uniqueName(sanitised || 'unnamed', used); + } + used.add(name); + layout.push({ entry, name: bytes(name) }); + } + return layout; +} + +/** + * Creates a standards-compliant, uncompressed ZIP stream. Object bytes are + * forwarded directly from the provider; only central-directory metadata stays + * in memory. + */ +export function createZipStream( + provider: StorageProvider, + entries: ZipEntry[], + prefix: string +): ReadableStream { + let cancelled = false; + let activeReader: ReadableStreamDefaultReader | undefined; + + async function* generate(): AsyncGenerator { + let offset = 0; + const centralEntries: CentralDirectoryEntry[] = []; + try { + for (const { entry, name } of archiveLayout(entries, prefix)) { + if (cancelled) return; + if (name.length > UINT16_MAX) throw new Error('Archive entry name is too long'); + const sizeZip64 = entry.size >= UINT32_MAX; + yield localHeader(name, sizeZip64); + const localOffset = offset; + offset += 30 + name.length + (sizeZip64 ? 20 : 0); + + let crc32 = 0; + let actualSize = 0; + if (!entry.isDirectory) { + const download = await provider.getObject(entry.key); + activeReader = download.stream.getReader(); + try { + while (!cancelled) { + const { done, value } = await activeReader.read(); + if (done) break; + crc32 = crc32Update(crc32, value); + actualSize += value.length; + offset += value.length; + yield value; + } + } finally { + await activeReader.cancel().catch(() => {}); + activeReader.releaseLock(); + activeReader = undefined; + } + } + if (cancelled) return; + if (actualSize !== entry.size) { + throw new Error(`Object size changed while creating archive: ${entry.key}`); + } + const entryZip64 = sizeZip64 || actualSize >= UINT32_MAX; + const descriptor = dataDescriptor(crc32, actualSize, entryZip64); + yield descriptor; + offset += descriptor.length; + centralEntries.push({ + name, + crc32, + size: actualSize, + offset: localOffset, + zip64: entryZip64, + isDirectory: entry.isDirectory + }); + } + + const centralOffset = offset; + for (const entry of centralEntries) { + const header = centralHeader(entry); + yield header; + offset += header.length; + } + yield endOfCentralDirectory(centralEntries.length, offset - centralOffset, centralOffset); + } finally { + await activeReader?.cancel().catch(() => {}); + } + } + const iterator = generate(); + return new ReadableStream( + { + async pull(controller) { + try { + const { done, value } = await iterator.next(); + if (cancelled) return; + if (done) controller.close(); + else controller.enqueue(value); + } catch (err) { + if (!cancelled) controller.error(err); + } + }, + async cancel() { + cancelled = true; + await activeReader?.cancel(); + await iterator.return(undefined); + } + }, + { highWaterMark: 0 } + ); +} diff --git a/src/lib/server/trino/client.test.ts b/src/lib/server/trino/client.test.ts index 358e2057..a7fd9524 100644 --- a/src/lib/server/trino/client.test.ts +++ b/src/lib/server/trino/client.test.ts @@ -5,9 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // vi.hoisted ensures the object exists before the hoisted vi.mock factory runs. const { mockEnv } = vi.hoisted(() => ({ mockEnv: {} as Record })); vi.mock('$env/dynamic/private', () => ({ env: mockEnv })); -vi.mock('$lib/server/logging', () => ({ - logger: { child: () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() }) } -})); +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); import { TrinoClient, trinoMetadataQuery } from './client.js'; diff --git a/src/lib/server/trino/result-collector.test.ts b/src/lib/server/trino/result-collector.test.ts index af04c7fc..5c471400 100644 --- a/src/lib/server/trino/result-collector.test.ts +++ b/src/lib/server/trino/result-collector.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; vi.mock('$env/dynamic/private', () => ({ env: {} })); -vi.mock('$lib/server/logging', () => ({ - logger: { child: () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }) } -})); +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); vi.mock('$lib/server/metrics.js', () => ({ trinoQueryTotal: { inc: vi.fn() }, trinoActiveQueries: { inc: vi.fn(), dec: vi.fn() } diff --git a/src/lib/storage/api.spec.ts b/src/lib/storage/api.spec.ts new file mode 100644 index 00000000..d4b99387 --- /dev/null +++ b/src/lib/storage/api.spec.ts @@ -0,0 +1,511 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createFetchStorageApi } from './api.js'; +import { StorageError } from './errors.js'; +import { STORAGE_CONNECTION_ID_HEADER } from './connection-id-header.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function jsonResponse(data: unknown, opts?: { status?: number }): Response { + return new Response(JSON.stringify(data), { + status: opts?.status ?? 200, + headers: { 'Content-Type': 'application/json' } + }); +} + +function ndjsonResponse(lines: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(lines.join('\n') + '\n')); + controller.close(); + } + }); + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'application/x-ndjson' } + }); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('createFetchStorageApi', () => { + describe('list', () => { + it('fetches objects for a bucket', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const page = { + objects: [ + { + key: 'file.txt', + size: 100, + lastModified: new Date().toISOString(), + isDirectory: false, + contentType: 'text/plain' + } + ], + hasNextPage: false, + currentPage: 1, + pageSize: 25 + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(page)); + + const result = await api.list({ bucket: 'my-bucket' }); + + expect(result.objects).toHaveLength(1); + expect(result.objects[0].key).toBe('file.txt'); + }); + + it('includes prefix and pageSize in the URL', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ objects: [], hasNextPage: false, currentPage: 1, pageSize: 100 }) + ); + + await api.list({ bucket: 'b', prefix: 'docs/', pageSize: 100 }); + + const url = vi.mocked(globalThis.fetch).mock.calls[0]![0] as string; + expect(url).toContain('bucket=b'); + expect(url).toContain('prefix=docs%2F'); + expect(url).toContain('pageSize=100'); + }); + + it('sets the connection ID header', async () => { + const api = createFetchStorageApi(() => 'conn-42'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ objects: [], hasNextPage: false, currentPage: 1, pageSize: 25 }) + ); + + await api.list({ bucket: 'b' }); + + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get(STORAGE_CONNECTION_ID_HEADER)).toBe('conn-42'); + }); + + it('throws StorageError when disconnected', async () => { + const api = createFetchStorageApi(() => null); + + await expect(api.list({ bucket: 'b' })).rejects.toThrow(StorageError); + await expect(api.list({ bucket: 'b' })).rejects.toMatchObject({ code: 'not_connected' }); + }); + + it('throws StorageError on server error', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(null, { status: 500 })); + + await expect(api.list({ bucket: 'b' })).rejects.toMatchObject({ code: 'server_error' }); + }); + }); + + describe('search', () => { + it('searches a bucket and forwards its abort signal', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const controller = new AbortController(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + ndjsonResponse([ + JSON.stringify({ + type: 'complete', + results: [ + { + key: 'report.txt', + size: 1, + lastModified: '2026-08-12T12:00:00.000Z', + isDirectory: false + } + ] + }) + ]) + ); + + const result = await api.search({ + bucket: 'documents', + query: 'report', + signal: controller.signal + }); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/search?bucket=documents&q=report'); + expect(init?.signal).toBe(controller.signal); + expect(result.results[0].lastModified).toBeInstanceOf(Date); + }); + }); + + describe('recent searches', () => { + it('lists recent searches', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse([{ buckets: ['documents'], query: 'report' }]) + ); + + await expect(api.listRecentSearches()).resolves.toEqual([ + { buckets: ['documents'], query: 'report' } + ]); + expect(vi.mocked(globalThis.fetch).mock.calls[0]![0]).toBe('/api/storage/search/history'); + }); + + it('records and clears recent searches', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); + + await api.recordRecentSearch({ + buckets: ['documents', 'archive'], + query: 'report', + useRegex: true, + excludePatterns: ['_temp'], + searchPath: 'events/', + maxDepth: 3 + }); + await api.clearRecentSearches(); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toBe('/api/storage/search/history'); + expect(init).toMatchObject({ method: 'POST' }); + expect(JSON.parse(String(init?.body))).toEqual({ + buckets: ['documents', 'archive'], + query: 'report', + useRegex: true, + excludePatterns: ['_temp'], + searchPath: 'events/', + maxDepth: 3 + }); + expect(vi.mocked(globalThis.fetch).mock.calls[1]![1]).toMatchObject({ method: 'DELETE' }); + }); + }); + + describe('delete', () => { + it('sends DELETE with keys in a JSON body', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ failed: [] })); + + const result = await api.delete({ bucket: 'b', keys: ['a.txt', 'b.txt'] }); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/delete'); + expect(url).toContain('bucket=b'); + expect(init?.method).toBe('DELETE'); + expect(new Headers(init?.headers).get('Content-Type')).toBe('application/json'); + expect(JSON.parse(init?.body as string)).toEqual({ keys: ['a.txt', 'b.txt'] }); + expect(result.failed).toEqual([]); + }); + + it('returns failed items from the server response', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + failed: [{ key: 'locked.txt', code: 'access_denied', message: 'No access' }] + }) + ); + + const result = await api.delete({ bucket: 'b', keys: ['locked.txt'] }); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].key).toBe('locked.txt'); + }); + }); + + describe('create', () => { + it('sends POST with bucket and key', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + + await api.create({ bucket: 'b', key: 'new-folder/' }); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/create'); + expect(url).toContain('bucket=b'); + expect(url).toContain('key=new-folder%2F'); + expect(init?.method).toBe('POST'); + }); + }); + + describe('archiveExtract', () => { + it('returns raw Response for blob download', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const fakeBody = new ReadableStream(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(fakeBody, { status: 200 })); + + const res = await api.archiveExtract({ bucket: 'b', key: 'archive.zip', path: 'file.txt' }); + + expect(res).toBeInstanceOf(Response); + const [url] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/archive/extract'); + expect(url).toContain('bucket=b'); + expect(url).toContain('key=archive.zip'); + expect(url).toContain('path=file.txt'); + }); + + it('includes nestedArchivePath when provided', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + + await api.archiveExtract({ + bucket: 'b', + key: 'outer.zip', + path: 'inner.zip/file.txt', + nestedArchivePath: 'inner.zip' + }); + + const [url] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('nestedArchivePath=inner.zip'); + }); + }); + + describe('archiveListing', () => { + it('returns parsed archive listing', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const listing = { + entries: [ + { key: 'file.txt', size: 100, lastModified: new Date().toISOString(), isDirectory: false } + ], + hasMore: false + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(listing)); + + const result = await api.archiveListing({ bucket: 'b', key: 'archive.zip' }); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].key).toBe('file.txt'); + expect(result.hasMore).toBe(false); + }); + + it('includes internalPrefix and nestedArchivePath', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ entries: [], hasMore: false }) + ); + + await api.archiveListing({ + bucket: 'b', + key: 'archive.zip', + internalPrefix: 'subdir/', + nestedArchivePath: 'inner.zip' + }); + + const [url] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('internalPrefix=subdir%2F'); + expect(url).toContain('nestedArchivePath=inner.zip'); + }); + }); + + describe('transfers', () => { + it('posts a plan and returns its items', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const items = [{ sourceKey: 'a', kind: 'file', destKey: 'd/a', conflict: 'none' }]; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ items })); + const body = { + op: 'copy' as const, + source: { bucket: 'b', keys: ['a'] }, + dest: { bucket: 'b', prefix: 'd/' } + }; + + expect(await api.planTransfer(body)).toEqual(items); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toBe('/api/storage/transfer/plan'); + expect(JSON.parse(init?.body as string)).toEqual(body); + }); + + it('passes every transfer event on and reports whether complete arrived', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const events = [ + { type: 'item-start', sourceKey: 'a', destRoot: 'd/a', totalObjects: 1, totalBytes: 3 }, + { + type: 'item-done', + sourceKey: 'a', + destRoot: 'd/a', + status: 'done', + copied: 1, + failed: [], + deletedOriginals: 0 + }, + { type: 'complete', summary: { done: 1, partial: 0, failed: 0, refused: 0, cancelled: 0 } } + ]; + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + ndjsonResponse(events.map((event) => JSON.stringify(event))) + ); + const onEvent = vi.fn(); + + const result = await api.transfer( + { + op: 'copy', + jobId: 'job-1', + source: { bucket: 'b' }, + dest: { bucket: 'b', prefix: 'd/' }, + items: [{ sourceKey: 'a' }] + }, + onEvent + ); + + expect(result).toEqual({ complete: true }); + expect(onEvent.mock.calls.map(([event]) => event)).toEqual(events); + expect(vi.mocked(globalThis.fetch).mock.calls[0]![0]).toBe('/api/storage/transfer'); + }); + + it('raises errors from the event handler and malformed lines instead of polling', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const body = { op: 'rename' as const, jobId: 'j', source: { bucket: 'b' }, items: [] }; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(ndjsonResponse(['{"type":"complete","summary":{}}'])) + .mockResolvedValueOnce(ndjsonResponse(['{"type":'])); + + await expect( + api.transfer(body, () => { + throw new Error('client bug'); + }) + ).rejects.toThrow('client bug'); + await expect(api.transfer(body, vi.fn())).rejects.toThrow('Malformed NDJSON line'); + }); + + it('treats a dropped connection as an incomplete stream', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"type":"progress"}\n')); + controller.error(new TypeError('network error')); + } + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(stream, { status: 200 })); + + expect( + await api.transfer( + { op: 'rename', jobId: 'j', source: { bucket: 'b' }, items: [] }, + vi.fn() + ) + ).toEqual({ complete: false }); + }); + + it('reports a stream that ends without complete', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ndjsonResponse([])); + + expect( + await api.transfer( + { op: 'rename', jobId: 'job-1', source: { bucket: 'b' }, items: [] }, + vi.fn() + ) + ).toEqual({ complete: false }); + }); + + it('raises request-level errors as StorageError', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ message: 'Job already exists' }, { status: 409 }) + ); + + await expect( + api.transfer({ op: 'rename', jobId: 'j', source: { bucket: 'b' }, items: [] }, vi.fn()) + ).rejects.toMatchObject({ code: 'conflict' }); + }); + + it('reads a transfer job, reporting a missing one as not_found', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ status: 'done', items: [] })) + .mockResolvedValueOnce(jsonResponse({ status: 'not_found' }, { status: 404 })); + + expect(await api.readTransferJob('job-1')).toEqual({ status: 'done', items: [] }); + expect(await api.readTransferJob('job-2')).toEqual({ status: 'not_found' }); + }); + }); + + describe('pollJob', () => { + it('fetches job status by ID', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const job = { + status: 'running', + progress: { completedBytes: 1024, currentFileName: 'data.bin' } + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(job)); + + const result = await api.pollJob('job-abc'); + + expect(result.status).toBe('running'); + expect(result.progress?.completedBytes).toBe(1024); + const [url] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/copy/job/job-abc'); + }); + + it('reports a missing job as not_found instead of throwing', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ status: 'not_found' }, { status: 404 }) + ); + + await expect(api.pollJob('job-abc')).resolves.toEqual({ status: 'not_found' }); + }); + }); + + describe('checkObjectExists', () => { + it('returns true for a successful HEAD request', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + + const exists = await api.checkObjectExists({ bucket: 'b', key: 'file.txt' }); + expect(exists).toBe(true); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/download'); + expect(url).toContain('bucket=b'); + expect(url).toContain('key=file.txt'); + expect(init?.method).toBe('HEAD'); + }); + + it('returns false for a 404 HEAD response', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 404 })); + + const exists = await api.checkObjectExists({ bucket: 'b', key: 'missing.txt' }); + expect(exists).toBe(false); + }); + + it('throws when disconnected instead of reporting absence', async () => { + const api = createFetchStorageApi(() => null); + + await expect(api.checkObjectExists({ bucket: 'b', key: 'file.txt' })).rejects.toMatchObject({ + code: 'not_connected' + }); + }); + }); + + describe('saveText', () => { + it('sends the loaded ETag and returns the new one', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ etag: '"new"' }), { status: 200 }) + ); + + const result = await api.saveText({ + bucket: 'b', + key: 'new file.txt', + body: 'new text', + etag: '"old"', + contentType: 'text/plain' + }); + + expect(result).toEqual({ etag: '"new"' }); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/save-text'); + expect(url).toContain('bucket=b'); + expect(url).toContain('key=new+file.txt'); + expect(url).toContain('etag=%22old%22'); + expect(url).not.toContain('previewBytes'); + expect(url).toContain('contentType=text%2Fplain'); + expect(init?.method).toBe('POST'); + expect(init?.body).toBe('new text'); + }); + + it('maps a 409 response to a conflict error', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ message: 'modified' }), { status: 409 }) + ); + + await expect( + api.saveText({ bucket: 'b', key: 'k', body: 'x', etag: '"old"', contentType: 'text/plain' }) + ).rejects.toMatchObject({ code: 'conflict' }); + }); + }); +}); diff --git a/src/lib/storage/api.test-utils.ts b/src/lib/storage/api.test-utils.ts new file mode 100644 index 00000000..a2993188 --- /dev/null +++ b/src/lib/storage/api.test-utils.ts @@ -0,0 +1,198 @@ +/** + * In-memory StorageApi implementation for unit tests. + * + * Provides sensible defaults (empty list, no-op mutations) and allows + * overriding any method via the `overrides` parameter. + * + * Usage: + * const api = createMemoryStorageApi({ + * list: async () => ({ objects: [mockFile], hasNextPage: false, ... }), + * }); + */ + +import type { StorageApi, DeleteResult, JobStatus } from './api.js'; +import type { StoragePage, ArchiveListingResponse } from './types.js'; +import type { FileDetails, DirectoryMetadata, BucketDetails } from './details-types.js'; +import type { + PlanItem, + TransferBody, + TransferEvent, + TransferItemResult, + TransferSummary +} from './transfer-types.js'; +import { keyToName, parentPrefix } from './utils.js'; + +/** Where an item of a transfer request lands when nothing is in the way. */ +export function plainDestRoot(body: TransferBody, item: TransferBody['items'][number]): string { + const suffix = item.sourceKey.endsWith('/') ? '/' : ''; + if (body.op === 'rename') return parentPrefix(item.sourceKey) + item.name + suffix; + const name = item.policy === 'rename' ? item.name! : keyToName(item.sourceKey); + return body.dest!.prefix + name + suffix; +} + +/** + * A `transfer` implementation that streams the given per-item outcomes (by + * source key; default `done`) and ends with `complete` unless `complete: false`. + */ +export function fakeTransfer( + outcomes: Record> = {}, + options: { complete?: boolean } = {} +): StorageApi['transfer'] { + return async (body, onEvent) => { + const summary: TransferSummary = { done: 0, partial: 0, failed: 0, refused: 0, cancelled: 0 }; + for (const item of body.items) { + const result: TransferItemResult = { + sourceKey: item.sourceKey, + destRoot: plainDestRoot(body, item), + status: 'done', + copied: 1, + failed: [], + deletedOriginals: body.op === 'copy' ? 0 : 1, + ...outcomes[item.sourceKey] + }; + summary[result.status]++; + onEvent({ type: 'item-done', ...result } satisfies TransferEvent); + } + const complete = options.complete ?? true; + if (complete) onEvent({ type: 'complete', summary }); + return { complete }; + }; +} + +const emptyPage: StoragePage = { + objects: [], + hasNextPage: false, + currentPage: 1, + pageSize: 25 +}; + +/** + * Create an in-memory `StorageApi` backed by sensible defaults. + * + * Every method returns a safe no-op value by default. Pass an `overrides` + * object to replace any subset of methods with custom behaviour. + */ +export function createMemoryStorageApi(overrides?: Partial): StorageApi { + const defaults: StorageApi = { + async list() { + return emptyPage; + }, + + async search() { + return { results: [] }; + }, + + async listRecentSearches() { + return []; + }, + + async recordRecentSearch() { + // no-op + }, + + async clearRecentSearches() { + // no-op + }, + + async planTransfer(body): Promise { + return body.source.keys.map((sourceKey) => ({ + sourceKey, + kind: sourceKey.endsWith('/') ? 'folder' : 'file', + destKey: body.dest.prefix + keyToName(sourceKey) + (sourceKey.endsWith('/') ? '/' : ''), + conflict: 'none' + })); + }, + + transfer: fakeTransfer(), + + async readTransferJob() { + return { status: 'not_found' }; + }, + + async delete(): Promise { + return { failed: [] }; + }, + + async create() { + // no-op + }, + + async archiveExtract() { + return new Response(null, { status: 200 }); + }, + + async archiveListing(): Promise { + return { entries: [], hasMore: false }; + }, + + async pollJob(): Promise { + return { status: 'done' }; + }, + + async cancelJob() { + // no-op + }, + + async createDownloadManifest() { + return { + id: 'download-manifest', + files: [], + expiresAt: new Date().toISOString() + }; + }, + + async listDownloadHistory() { + return []; + }, + + async clearDownloadHistory() { + // no-op + }, + + async recreateDownloadManifest() { + return { id: 'download-manifest', files: [], expiresAt: new Date().toISOString() }; + }, + + async checkObjectExists() { + return false; + }, + + async download() { + return new Response(null, { status: 200 }); + }, + + async preview() { + return new Response(null, { status: 200 }); + }, + + async saveText() { + return { etag: null }; + }, + + async details(): Promise { + return {} as FileDetails; + }, + + async directoryMetadata(): Promise { + return {} as DirectoryMetadata; + }, + + async directorySize() { + return new Response(null, { status: 200 }); + }, + + async bucketDetails(): Promise { + return {} as BucketDetails; + }, + + async checkBucket() { + return { ok: true, status: 200 }; + }, + + async updateConnections() { + // no-op + } + }; + + return { ...defaults, ...overrides }; +} diff --git a/src/lib/storage/api.ts b/src/lib/storage/api.ts new file mode 100644 index 00000000..ed6433cf --- /dev/null +++ b/src/lib/storage/api.ts @@ -0,0 +1,471 @@ +/** + * Typed StorageApi interface and factory. + * + * Provides a single entry point for all S3 storage operations, abstracting + * the HTTP transport behind a clean interface. This enables: + * - Centralised error handling and connection management + * - Type-safe method signatures matching the server API contract + * + * Create via: + * const api = createFetchStorageApi(() => connectionStore.activeConnectionId); + */ + +import { STORAGE_CONNECTION_ID_HEADER } from './connection-id-header.js'; +import { createStorageFetch } from './storage-fetch.js'; +import { StorageError } from './errors.js'; +import { NdjsonParseError, readNdjsonLines } from './ndjson-stream.js'; +import { serializeFilter, type SearchFilterSpec } from './search-filter.js'; +import type { + StoragePage, + ArchiveListingResponse, + StorageSearchResponse, + StorageSearchUpdate, + RecentSearchEntry +} from './types.js'; +import type { FileDetails, DirectoryMetadata, BucketDetails } from './details-types.js'; +import type { + PlanItem, + TransferBody, + TransferEvent, + TransferJobState, + TransferPlanBody +} from './transfer-types.js'; + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface DeleteResult { + failed: Array<{ key: string; code?: string; message?: string }>; +} + +export interface JobStatus { + status: string; + /** Transfers only: one job covers all items, each with its own result. */ + items?: Array<{ status: string }>; + progress?: { + completedCount?: number; + completedBytes?: number; + currentFileName?: string; + }; +} + +export interface DownloadManifestResponse { + id: string; + files: Array<{ filename: string; size: number; part: number }>; + expiresAt: string; +} + +export interface DownloadHistoryEntry { + id: string; + bucket: string; + connectionId: string; + entries: Array<{ key: string; size: number; isDirectory: boolean }>; + archive: boolean; + archiveFilename: string | null; + createdAt: string; + expiresAt: string; +} + +// ── Interface ────────────────────────────────────────────────────────────── + +export interface StorageApi { + list(params: { bucket: string; prefix?: string; pageSize?: number }): Promise; + + search(params: { + bucket: string; + query: string; + prefix?: string; + maxDepth?: number; + useRegex?: boolean; + excludePatterns?: string[]; + filters?: SearchFilterSpec[]; + signal?: AbortSignal; + onUpdate?: (update: StorageSearchUpdate) => void; + }): Promise; + + listRecentSearches(): Promise; + + recordRecentSearch(params: { + buckets: string[]; + query: string; + useRegex?: boolean; + excludePatterns?: string[]; + searchPath?: string; + maxDepth?: number | null; + }): Promise; + + clearRecentSearches(): Promise; + + /** Preview a copy or move for the conflict dialog. */ + planTransfer(body: TransferPlanBody, signal?: AbortSignal): Promise; + + /** + * Run a copy, move or rename, passing every NDJSON event to `onEvent`. + * Resolves with whether the stream ended with `complete`; throws a + * StorageError for request-level HTTP errors. + */ + transfer( + body: TransferBody, + onEvent: (event: TransferEvent) => void + ): Promise<{ complete: boolean }>; + + /** Read a transfer job, e.g. after its stream ended without `complete`. */ + readTransferJob(jobId: string): Promise; + + delete(params: { bucket: string; keys: string[] }): Promise; + + create(params: { bucket: string; key: string }): Promise; + + archiveExtract(params: { + bucket: string; + key: string; + path: string; + nestedArchivePath?: string; + }): Promise; + + archiveListing(params: { + bucket: string; + key: string; + internalPrefix?: string; + nestedArchivePath?: string; + }): Promise; + + pollJob(jobId: string): Promise; + + cancelJob(jobId: string): Promise; + + createDownloadManifest( + params: { + bucket: string; + prefix: string; + keys: string[]; + }, + signal?: AbortSignal + ): Promise; + + listDownloadHistory(connectionId: string): Promise; + + clearDownloadHistory(): Promise; + + recreateDownloadManifest( + manifestId: string, + keys: string[], + signal?: AbortSignal + ): Promise; + + checkObjectExists(params: { bucket: string; key: string }): Promise; + + download(params: { bucket: string; key: string }): Promise; + + preview(params: { + bucket: string; + key: string; + offset?: number; + limit?: number; + data?: boolean; + }): Promise; + + /** + * Replace a text object with `body`. `etag` is the ETag the content was + * loaded with; the server rejects the save with 409 (`conflict`) when the + * object changed since. Resolves to the ETag of the written object. + */ + saveText(params: { + bucket: string; + key: string; + body: string; + etag: string; + contentType: string; + }): Promise<{ etag: string | null }>; + + details(params: { bucket: string; key: string }): Promise; + + directoryMetadata(params: { bucket: string; prefix: string }): Promise; + + directorySize(params: { + bucket: string; + prefix: string; + signal?: AbortSignal; + }): Promise; + + bucketDetails(params: { bucket: string }): Promise; + + checkBucket(params: { bucket: string }): Promise<{ ok: boolean; status: number }>; + + updateConnections(params: { bucket: string }): Promise; +} + +// ── Factory ──────────────────────────────────────────────────────────────── + +/** + * Create a `StorageApi` instance backed by real HTTP fetch calls. + * + * @param getConnectionId A function returning the active connection ID + * (or `null` when disconnected). + */ +export function createFetchStorageApi(getConnectionId: () => string | null): StorageApi { + const fetch_ = createStorageFetch(getConnectionId); + + return { + async list({ bucket, prefix = '', pageSize }) { + const params = new URLSearchParams({ bucket, prefix: prefix ?? '' }); + if (pageSize !== undefined) { + params.set('pageSize', String(pageSize)); + } + const res = await fetch_(`/api/storage/list?${params}`); + return (await res.json()) as StoragePage; + }, + + async search({ + bucket, + query, + prefix, + maxDepth, + useRegex, + excludePatterns, + filters, + signal, + onUpdate + }) { + const params = new URLSearchParams({ bucket, q: query }); + if (prefix) params.set('prefix', prefix); + if (maxDepth !== undefined) params.set('maxDepth', String(maxDepth)); + if (useRegex) params.set('regex', 'true'); + for (const pattern of excludePatterns ?? []) params.append('exclude', pattern); + for (const filter of filters ?? []) params.append('filter', serializeFilter(filter)); + if (filters?.length) params.set('tz', Intl.DateTimeFormat().resolvedOptions().timeZone); + const res = await fetch_(`/api/storage/search?${params}`, { signal }); + const { readSearchStream } = await import('./search-stream.js'); + return readSearchStream(res.body, onUpdate); + }, + + async listRecentSearches() { + const res = await fetch_('/api/storage/search/history'); + return (await res.json()) as RecentSearchEntry[]; + }, + + async recordRecentSearch({ buckets, query, useRegex, excludePatterns, searchPath, maxDepth }) { + await fetch_('/api/storage/search/history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ buckets, query, useRegex, excludePatterns, searchPath, maxDepth }) + }); + }, + + async clearRecentSearches() { + await fetch_('/api/storage/search/history', { method: 'DELETE' }); + }, + + async planTransfer(body, signal) { + const res = await fetch_('/api/storage/transfer/plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + return ((await res.json()) as { items: PlanItem[] }).items; + }, + + async transfer(body, onEvent) { + const res = await fetch_('/api/storage/transfer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + let complete = false; + let handlerError: unknown = null; + try { + await readNdjsonLines(res.body, (line) => { + const event = line as TransferEvent; + if (event.type === 'complete') complete = true; + try { + onEvent(event); + } catch (err) { + handlerError = err; + throw err; + } + }); + } catch (err) { + // A client bug or a malformed line is not a dropped connection: report it. + if (handlerError !== null || err instanceof NdjsonParseError) throw err; + // The connection dropped; the caller reads the outcome from the job. + } + return { complete }; + }, + + async readTransferJob(jobId) { + try { + const res = await fetch_(`/api/storage/copy/job/${encodeURIComponent(jobId)}`); + return (await res.json()) as TransferJobState; + } catch (err) { + if (err instanceof StorageError && err.code === 'not_found') return { status: 'not_found' }; + throw err; + } + }, + + async delete({ bucket, keys }) { + const params = new URLSearchParams({ bucket }); + const res = await fetch_(`/api/storage/delete?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys }) + }); + return (await res.json()) as DeleteResult; + }, + + async create({ bucket, key }) { + const params = new URLSearchParams({ bucket, key }); + await fetch_(`/api/storage/create?${params}`, { + method: 'POST' + }); + }, + + async archiveExtract({ bucket, key, path: filePath, nestedArchivePath }) { + const params = new URLSearchParams({ bucket, key, path: filePath }); + if (nestedArchivePath) { + params.set('nestedArchivePath', nestedArchivePath); + } + // Returns raw Response for blob download — caller consumes the body. + return fetch_(`/api/storage/archive/extract?${params}`); + }, + + async archiveListing({ bucket, key, internalPrefix = '', nestedArchivePath }) { + const params = new URLSearchParams({ bucket, key, internalPrefix }); + if (nestedArchivePath) { + params.set('nestedArchivePath', nestedArchivePath); + } + const res = await fetch_(`/api/storage/archive/listing?${params}`); + return (await res.json()) as ArchiveListingResponse; + }, + + async pollJob(jobId) { + try { + const res = await fetch_(`/api/storage/copy/job/${jobId}`); + return (await res.json()) as JobStatus; + } catch (err) { + if (err instanceof StorageError && err.code === 'not_found') return { status: 'not_found' }; + throw err; + } + }, + + async cancelJob(jobId) { + await fetch_(`/api/storage/copy/job/${encodeURIComponent(jobId)}`, { method: 'DELETE' }); + }, + + async createDownloadManifest({ bucket, prefix, keys }, signal) { + const params = new URLSearchParams({ bucket, prefix }); + const res = await fetch_(`/api/storage/download/manifests?${params}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys }), + signal + }); + return (await res.json()) as DownloadManifestResponse; + }, + + async listDownloadHistory(connectionId) { + const history: DownloadHistoryEntry[] = []; + for (let offset = 0; ; offset += 50) { + const res = await fetch( + `/api/storage/download/manifests?${new URLSearchParams({ connectionId, offset: String(offset) })}` + ); + if (!res.ok) throw new Error(`Request failed with status ${res.status}`); + const entries = (await res.json()) as DownloadHistoryEntry[]; + history.push(...entries); + if (entries.length < 50) return history; + } + }, + + async clearDownloadHistory() { + await fetch_('/api/storage/download/manifests', { method: 'DELETE' }); + }, + + async recreateDownloadManifest(manifestId, keys, signal) { + const res = await fetch_( + `/api/storage/download/manifests/${encodeURIComponent(manifestId)}/redownload`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys }), + signal + } + ); + return (await res.json()) as DownloadManifestResponse; + }, + + async checkObjectExists({ bucket, key }) { + const params = new URLSearchParams({ bucket, key }); + try { + // storageFetch throws on any non-2xx response, so reaching here means it exists. + await fetch_(`/api/storage/download?${params}`, { method: 'HEAD' }); + return true; + } catch (err) { + if (err instanceof StorageError && err.code === 'not_found') return false; + throw err; + } + }, + + async download({ bucket, key }) { + const params = new URLSearchParams({ bucket, key }); + return fetch_(`/api/storage/download?${params}`); + }, + + async preview({ bucket, key, offset, limit, data }) { + const params = new URLSearchParams({ bucket, key }); + if (offset !== undefined) params.set('offset', String(offset)); + if (limit !== undefined) params.set('limit', String(limit)); + if (data) params.set('data', 'true'); + return fetch_(`/api/storage/preview?${params}`); + }, + + async saveText({ bucket, key, body, etag, contentType }) { + const params = new URLSearchParams({ bucket, key, etag, contentType }); + const res = await fetch_(`/api/storage/save-text?${params}`, { + method: 'POST', + body + }); + const result = (await res.json().catch(() => null)) as { etag?: unknown } | null; + return { etag: typeof result?.etag === 'string' ? result.etag : null }; + }, + + async details({ bucket, key }) { + const params = new URLSearchParams({ bucket, key }); + const res = await fetch_(`/api/storage/details?${params}`); + return (await res.json()) as FileDetails; + }, + + async directoryMetadata({ bucket, prefix }) { + const params = new URLSearchParams({ bucket, prefix, metadata: 'true' }); + const res = await fetch_(`/api/storage/directory-metadata?${params}`); + return (await res.json()) as DirectoryMetadata; + }, + + async directorySize({ bucket, prefix, signal }) { + const params = new URLSearchParams({ bucket, prefix }); + return fetch_(`/api/storage/directory-size?${params}`, { signal }); + }, + + async bucketDetails({ bucket }) { + const params = new URLSearchParams({ bucket, details: 'true' }); + const res = await fetch_(`/api/storage/buckets?${params}`); + return (await res.json()) as BucketDetails; + }, + + async checkBucket({ bucket }) { + const params = new URLSearchParams({ bucket }); + const res = await fetch(`/api/storage/check-bucket?${params}`, { + headers: { [STORAGE_CONNECTION_ID_HEADER]: getConnectionId() ?? '' } + }); + return { ok: res.ok, status: res.status }; + }, + + async updateConnections({ bucket }) { + await fetch_(`/api/storage/connections`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bucket }) + }); + } + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── diff --git a/src/lib/storage/archive.svelte.spec.ts b/src/lib/storage/archive.svelte.spec.ts new file mode 100644 index 00000000..4b35a774 --- /dev/null +++ b/src/lib/storage/archive.svelte.spec.ts @@ -0,0 +1,606 @@ +vi.mock('$app/environment', () => ({ browser: true })); +vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() })); +vi.mock('$lib/stores/toast.svelte.js', () => ({ addToast: vi.fn() })); + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ArchiveState } from './archive.svelte.js'; +import { addToast } from '$lib/stores/toast.svelte.js'; +import { invalidateAll } from '$app/navigation'; +import type { Mock } from 'vitest'; +import type { StorageApi } from './api.js'; +import type { StoragePage } from './types.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockApi(): StorageApi { + return { + list: vi.fn(), + copy: vi.fn(), + move: vi.fn(), + rename: vi.fn(), + delete: vi.fn(), + create: vi.fn(), + archiveExtract: vi.fn(), + archiveListing: vi.fn(), + pollJob: vi.fn(), + checkObjectExists: vi.fn(), + preview: vi.fn(), + saveText: vi.fn(), + details: vi.fn(), + directoryMetadata: vi.fn(), + directorySize: vi.fn(), + bucketDetails: vi.fn(), + checkBucket: vi.fn(), + updateConnections: vi.fn() + } as unknown as StorageApi; +} + +function makeArchive(apiOverrides?: Partial): { + archive: ArchiveState; + callbacks: ReturnType; + api: StorageApi; +} { + const api = makeMockApi(); + Object.assign(api, apiOverrides ?? {}); + const callbacks = makeCallbacks(); + const archive = new ArchiveState(api, callbacks); + return { archive, callbacks, api }; +} + +function makeCallbacks() { + return { + getBucket: vi.fn().mockReturnValue('test-bucket'), + getPrefix: vi.fn().mockReturnValue('s3/prefix/'), + getPageSize: vi.fn().mockReturnValue(25), + setObjects: vi.fn(), + setLoading: vi.fn(), + setPrefix: vi.fn(), + clearPrevTokens: vi.fn(), + onExit: vi.fn() + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 1: State basics +// ───────────────────────────────────────────────────────────────────────────── + +describe('state basics', () => { + it('isInArchive is false when archiveKey is null', () => { + const { archive } = makeArchive(); + expect(archive.isInArchive).toBe(false); + }); + + it('isInArchive is true when archiveKey is set', () => { + const { archive } = makeArchive(); + archive.archiveKey = 'test.zip'; + expect(archive.isInArchive).toBe(true); + }); + + it('isInArchive transitions correctly when archiveKey changes', () => { + const { archive } = makeArchive(); + expect(archive.isInArchive).toBe(false); + archive.archiveKey = 'test.zip'; + expect(archive.isInArchive).toBe(true); + archive.archiveKey = null; + expect(archive.isInArchive).toBe(false); + }); + + it('nestedArchivePath getter returns undefined when not set', () => { + const { archive } = makeArchive(); + expect(archive.nestedArchivePath).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 3: enterArchive +// ───────────────────────────────────────────────────────────────────────────── + +describe('enterArchive', () => { + it('fetches listing and sets state on success', async () => { + const { archive, callbacks, api } = makeArchive(); + (api.archiveListing as Mock).mockResolvedValue({ + entries: [ + { key: 'file.txt', size: 100, lastModified: new Date(), isDirectory: false }, + { key: 'subdir/', size: 0, lastModified: new Date(), isDirectory: true } + ], + hasMore: false + }); + + await archive.enterArchive('archive.zip'); + + expect(archive.archiveKey).toBe('archive.zip'); + expect(archive.archivePrefix).toBe(''); + expect(archive.isInArchive).toBe(true); + expect(api.archiveListing).toHaveBeenCalledWith({ + bucket: 'test-bucket', + key: 'archive.zip', + internalPrefix: '', + nestedArchivePath: undefined + }); + expect(archive.archiveLoading).toBe(false); + expect(callbacks.setObjects).toHaveBeenCalled(); + const page = callbacks.setObjects.mock.calls[0][0] as StoragePage; + expect(page.objects).toHaveLength(2); + expect(page.objects[0].key).toBe('file.txt'); + expect(page.objects[1].key).toBe('subdir/'); + }); + + it('saves previous S3 prefix when entering archive', async () => { + const { archive, callbacks, api } = makeArchive(); + callbacks.getPrefix.mockReturnValue('my/s3/path/'); + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + await archive.enterArchive('archive.zip'); + + // _previousS3Prefix was set before fetch + // We verify indirectly by checking exitArchive uses it + archive.exitArchive(); + expect(callbacks.onExit).toHaveBeenCalledWith('my/s3/path/'); + }); + + it('handles nested archive entry', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'outer.zip'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + await archive.enterArchive('inner.tar'); + + expect(archive.archiveKey).toBe('outer.zip'); // unchanged + expect(archive.nestedArchivePath).toBe('inner.tar'); + expect(archive.archivePrefix).toBe(''); + }); + + it('shows toast on error', async () => { + const { archive, api } = makeArchive(); + (api.archiveListing as Mock).mockRejectedValue(new Error('API error')); + + await archive.enterArchive('archive.zip'); + + expect(archive.archiveKey).toBeNull(); + expect(addToast).toHaveBeenCalledWith('error', expect.any(String)); + expect(archive.archiveLoading).toBe(false); + }); + + it('handles error during nested archive entry and restores nested path', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'outer.zip'; + archive.archivePrefix = 'some/path/'; + (api.archiveListing as Mock).mockRejectedValue(new Error('Nested error')); + + await archive.enterArchive('inner.tar'); + + // Should restore nested path to null + expect(archive.nestedArchivePath).toBeUndefined(); + expect(archive.archiveKey).toBe('outer.zip'); // outer archive preserved + expect(addToast).toHaveBeenCalledWith('error', expect.any(String)); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 4: navigateInArchive +// ───────────────────────────────────────────────────────────────────────────── + +describe('navigateInArchive', () => { + it('fetches listing with new prefix', async () => { + const { archive, callbacks, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + await archive.navigateInArchive('subdir/'); + + expect(archive.archivePrefix).toBe('subdir/'); + expect(api.archiveListing).toHaveBeenCalledWith({ + bucket: 'test-bucket', + key: 'archive.zip', + internalPrefix: 'subdir/', + nestedArchivePath: undefined + }); + expect(archive.archiveLoading).toBe(false); + expect(callbacks.clearPrevTokens).toHaveBeenCalled(); + }); + + it('does nothing when no archive is open', async () => { + const { archive, api } = makeArchive(); + + await archive.navigateInArchive('subdir/'); + + expect(api.archiveListing).not.toHaveBeenCalled(); + expect(archive.archivePrefix).toBe(''); + }); + + it('shows toast on error', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + (api.archiveListing as Mock).mockRejectedValue(new Error('Fail')); + + await archive.navigateInArchive('subdir/'); + + expect(addToast).toHaveBeenCalledWith('error', expect.any(String)); + expect(archive.archiveLoading).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 5: navigateToOuterArchiveRoot +// ───────────────────────────────────────────────────────────────────────────── + +describe('navigateToOuterArchiveRoot', () => { + it('clears nested path and fetches root listing', async () => { + const { archive, callbacks, api } = makeArchive(); + archive.archiveKey = 'outer.zip'; + // Simulate being inside a nested archive + // _archiveNestedPath is private, but we can call navigateToOuterArchiveRoot directly + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + // Set nested state via enterArchive + archive.archivePrefix = 'subdir/'; + await archive.enterArchive('inner.tar'); // sets _archiveNestedPath + vi.clearAllMocks(); + + archive.navigateToOuterArchiveRoot(); + + expect(callbacks.clearPrevTokens).toHaveBeenCalled(); + expect(api.archiveListing).toHaveBeenCalledWith( + expect.objectContaining({ internalPrefix: '' }) + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 6: navigateUpFromArchive +// ───────────────────────────────────────────────────────────────────────────── + +describe('navigateUpFromArchive', () => { + it('goes up one directory level within the archive', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + archive.archivePrefix = 'music/videos/'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + archive.navigateUpFromArchive(); + + // Wait for async navigateInArchive + await vi.waitFor(() => { + expect(archive.archivePrefix).toBe('music/'); + }); + }); + + it('exits archive when at root without nested path', () => { + const { archive, callbacks } = makeArchive(); + archive.archiveKey = 'archive.zip'; + archive.archivePrefix = ''; + + archive.navigateUpFromArchive(); + + expect(callbacks.onExit).toHaveBeenCalled(); + expect(archive.archiveKey).toBeNull(); + }); + + it('goes to outer archive root when at root with nested path', async () => { + const { archive, callbacks, api } = makeArchive(); + archive.archiveKey = 'outer.zip'; + archive.archivePrefix = ''; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + // Enter nested archive to set _archiveNestedPath + await archive.enterArchive('inner.tar'); + vi.clearAllMocks(); + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + archive.navigateUpFromArchive(); + + expect(archive.nestedArchivePath).toBeUndefined(); + expect(api.archiveListing).toHaveBeenCalledWith( + expect.objectContaining({ internalPrefix: '' }) + ); + expect(callbacks.clearPrevTokens).toHaveBeenCalled(); + }); + + it('goes one level up when not at root', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + archive.archivePrefix = 'a/b/'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + archive.navigateUpFromArchive(); + + await vi.waitFor(() => { + expect(archive.archivePrefix).toBe('a/'); + }); + }); + + it('goes to root when at top-level prefix', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + archive.archivePrefix = 'docs/'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + archive.navigateUpFromArchive(); + + await vi.waitFor(() => { + expect(archive.archivePrefix).toBe(''); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 7: exitArchive +// ───────────────────────────────────────────────────────────────────────────── + +describe('exitArchive', () => { + it('clears state and calls onExit with previous S3 prefix', async () => { + const { archive, callbacks, api } = makeArchive(); + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + // Enter archive first to set _previousS3Prefix from getPrefix callback + await archive.enterArchive('archive.zip'); + + archive.exitArchive(); + + expect(archive.archiveKey).toBeNull(); + expect(archive.archivePrefix).toBe(''); + expect(archive.archiveLoading).toBe(false); + expect(archive.archiveTooLarge).toBe(false); + expect(archive.isInArchive).toBe(false); + expect(callbacks.onExit).toHaveBeenCalledWith('s3/prefix/'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 8: downloadFromArchive +// ───────────────────────────────────────────────────────────────────────────── + +describe('downloadFromArchive', () => { + it('downloads blob and creates download link', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + const blob = new Blob(['test content']); + (api.archiveExtract as Mock).mockResolvedValue(new Response(blob)); + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fake-url'); + + await archive.downloadFromArchive('path/to/file.txt'); + + expect(api.archiveExtract).toHaveBeenCalledWith({ + bucket: 'test-bucket', + key: 'archive.zip', + path: 'path/to/file.txt', + nestedArchivePath: undefined + }); + expect(createObjectURLSpy).toHaveBeenCalled(); + createObjectURLSpy.mockRestore(); + }); + + it('does nothing when no archive is open', async () => { + const { archive, api } = makeArchive(); + + await archive.downloadFromArchive('path/to/file.txt'); + + expect(api.archiveExtract).not.toHaveBeenCalled(); + }); + + it('shows error toast on StorageError', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + (api.archiveExtract as Mock).mockRejectedValue( + new (await import('./errors.js')).StorageError('not_found', 'Not found') + ); + + await archive.downloadFromArchive('missing.txt'); + + expect(addToast).toHaveBeenCalledWith('error', expect.any(String)); + }); + + it('shows generic error toast on unknown error', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + (api.archiveExtract as Mock).mockRejectedValue(new Error('Network error')); + + await archive.downloadFromArchive('file.txt'); + + expect(addToast).toHaveBeenCalledWith('error', expect.any(String)); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 9: refreshListing +// ───────────────────────────────────────────────────────────────────────────── + +describe('refreshListing', () => { + it('re-fetches archive listing', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + archive.refreshListing(); + + // Wait for the async fetch + await vi.waitFor(() => { + expect(api.archiveListing).toHaveBeenCalled(); + }); + expect(archive.archiveLoading).toBe(false); + }); + + it('sets loading to false on error', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'archive.zip'; + (api.archiveListing as Mock).mockRejectedValue(new Error('Fail')); + + archive.refreshListing(); + + await vi.waitFor(() => { + expect(archive.archiveLoading).toBe(false); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 10: reset +// ───────────────────────────────────────────────────────────────────────────── + +describe('reset', () => { + it('clears all archive state', () => { + const { archive } = makeArchive(); + archive.archiveKey = 'archive.zip'; + archive.archivePrefix = 'subdir/'; + archive.archiveLoading = true; + archive.archiveTooLarge = true; + // Set internal state via enterArchive + // (nested path is set internally — we verify reset clears it via exitArchive behavior) + + archive.reset(); + + expect(archive.archiveKey).toBeNull(); + expect(archive.archivePrefix).toBe(''); + expect(archive.archiveLoading).toBe(false); + expect(archive.archiveTooLarge).toBe(false); + expect(archive.isInArchive).toBe(false); + expect(archive.nestedArchivePath).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 11: _fetchS3Objects (internal) +// ───────────────────────────────────────────────────────────────────────────── + +describe('_fetchS3Objects', () => { + it('calls setObjects and setLoading on success', async () => { + const { archive, callbacks, api } = makeArchive(); + const mockPage: StoragePage = { + objects: [], + hasNextPage: false, + currentPage: 1, + pageSize: 25 + }; + (api.list as Mock).mockResolvedValue(mockPage); + + await archive._fetchS3Objects('my-prefix/'); + + expect(api.list).toHaveBeenCalledWith({ + bucket: 'test-bucket', + prefix: 'my-prefix/', + pageSize: 25 + }); + expect(callbacks.setPrefix).toHaveBeenCalledWith('my-prefix/'); + expect(callbacks.setObjects).toHaveBeenCalledWith(mockPage); + expect(callbacks.setLoading).toHaveBeenCalledWith(false); + }); + + it('falls back to invalidateAll on error', async () => { + const { archive, callbacks, api } = makeArchive(); + (api.list as Mock).mockRejectedValue(new Error('Network error')); + + await archive._fetchS3Objects('prefix/'); + + expect(invalidateAll).toHaveBeenCalled(); + expect(callbacks.setLoading).toHaveBeenCalledWith(false); + }); + + it('passes empty prefix string when given undefined-like value', async () => { + const { archive, api } = makeArchive(); + (api.list as Mock).mockResolvedValue({ + objects: [], + hasNextPage: false, + currentPage: 1, + pageSize: 25 + }); + + await archive._fetchS3Objects(''); + + expect(api.list).toHaveBeenCalledWith(expect.objectContaining({ prefix: '' })); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 12: Too-large archives +// ───────────────────────────────────────────────────────────────────────────── + +describe('too-large archives', () => { + it('sets archiveTooLarge and clears objects when archive listing returns tooLarge', async () => { + const { archive, callbacks, api } = makeArchive(); + (api.archiveListing as Mock).mockResolvedValue({ + entries: [], + hasMore: false, + tooLarge: true + }); + + await archive.enterArchive('huge.zip'); + + expect(archive.archiveTooLarge).toBe(true); + expect(archive.archiveLoading).toBe(false); + expect(callbacks.setObjects).toHaveBeenCalledWith({ + objects: [], + hasNextPage: false, + currentPage: 1, + pageSize: null + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 13: Nested archive navigation +// ───────────────────────────────────────────────────────────────────────────── + +describe('nested archive navigation', () => { + it('entering a nested archive preserves outer archiveKey', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'outer.zip'; + archive.archivePrefix = 'some/path/'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + await archive.enterArchive('inner.tar'); + + expect(archive.archiveKey).toBe('outer.zip'); + expect(archive.nestedArchivePath).toBe('inner.tar'); + expect(archive.archivePrefix).toBe(''); + expect(api.archiveListing).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'outer.zip', + nestedArchivePath: 'inner.tar' + }) + ); + }); + + it('navigating within nested archive keeps the nested path', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'outer.zip'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + await archive.enterArchive('inner.tar'); + vi.clearAllMocks(); + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + await archive.navigateInArchive('subdir/'); + + expect(api.archiveListing).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'outer.zip', + nestedArchivePath: 'inner.tar', + internalPrefix: 'subdir/' + }) + ); + }); + + it('navigateToOuterArchiveRoot exits nested archive', async () => { + const { archive, api } = makeArchive(); + archive.archiveKey = 'outer.zip'; + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + await archive.enterArchive('inner.tar'); + vi.clearAllMocks(); + (api.archiveListing as Mock).mockResolvedValue({ entries: [], hasMore: false }); + + archive.navigateToOuterArchiveRoot(); + + await vi.waitFor(() => { + expect(api.archiveListing).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'outer.zip', + nestedArchivePath: undefined, + internalPrefix: '' + }) + ); + }); + expect(archive.nestedArchivePath).toBeUndefined(); + }); +}); diff --git a/src/lib/storage/archive.svelte.ts b/src/lib/storage/archive.svelte.ts new file mode 100644 index 00000000..fb746e9a --- /dev/null +++ b/src/lib/storage/archive.svelte.ts @@ -0,0 +1,281 @@ +import { invalidateAll } from '$app/navigation'; +import * as m from '$lib/paraglide/messages.js'; +import { addToast } from '$lib/stores/toast.svelte.js'; +import { StorageError, getActionErrorMessage } from './errors.js'; +import type { StoragePage } from '$lib/storage/types.js'; +import type { StorageApi } from './api.js'; + +// ── ArchiveState ─────────────────────────────────────────────────────────── + +export class ArchiveState { + // ── Reactive state (readable by UI components) ───────────────────────── + archiveKey = $state(null); + /** Virtual path prefix within the archive ('' = root). */ + archivePrefix = $state(''); + archiveLoading = $state(false); + archiveTooLarge = $state(false); + isInArchive = $derived(this.archiveKey !== null); + + /** Internal archive nested path, exposed via getter for access control. */ + private _archiveNestedPath = $state(null); + + get nestedArchivePath(): string | undefined { + return this._archiveNestedPath ?? undefined; + } + + /** + * S3 prefix we were at before entering the archive. + * Internal field — read via `archive.archivePrefix` stack. + * Set from snapshot by `_restoreFullState`. + */ + _previousS3Prefix = $state(''); + + // ── Dependencies ─────────────────────────────────────────────────────── + + private _api: StorageApi; + + /** Callbacks to mutate StorageState fields without coupling directly. */ + private _callbacks: { + getBucket: () => string; + getPrefix: () => string; + getPageSize: () => number; + setObjects: (objects: StoragePage) => void; + setLoading: (loading: boolean) => void; + setPrefix: (prefix: string) => void; + clearPrevTokens: () => void; + /** Called when the user exits archive — StorageState should navigate to S3. */ + onExit: (s3Prefix: string) => void; + }; + + constructor( + api: StorageApi, + callbacks: { + getBucket: () => string; + getPrefix: () => string; + getPageSize: () => number; + setObjects: (objects: StoragePage) => void; + setLoading: (loading: boolean) => void; + setPrefix: (prefix: string) => void; + clearPrevTokens: () => void; + onExit: (s3Prefix: string) => void; + } + ) { + this._api = api; + this._callbacks = callbacks; + } + + // ── Public methods ───────────────────────────────────────────────────── + + /** Enter an archive file and show its contents as a virtual folder. */ + async enterArchive(archiveKey: string): Promise { + if (this.isInArchive) { + this._archiveNestedPath = archiveKey; + } else { + this.archiveKey = archiveKey; + this._archiveNestedPath = null; + this._previousS3Prefix = this._callbacks.getPrefix(); + } + this.archivePrefix = ''; + this.archiveLoading = true; + + try { + await this._fetchArchiveListing(); + } catch (err) { + if (this._archiveNestedPath) { + this._archiveNestedPath = null; + } else { + this.archiveKey = null; + this._previousS3Prefix = ''; + } + this.archivePrefix = ''; + this.archiveLoading = false; + addToast('error', err instanceof Error ? err.message : m.storage_archive_open_error()); + } + } + + /** Navigate within the current archive (virtual path). */ + async navigateInArchive(prefix: string): Promise { + if (!this.archiveKey) return; + this.archivePrefix = prefix; + this.archiveLoading = true; + this._callbacks.clearPrevTokens(); + + try { + await this._fetchArchiveListing(); + } catch (err) { + this.archiveLoading = false; + addToast('error', err instanceof Error ? err.message : m.storage_archive_open_error()); + } + } + + /** Navigate to the root of the outermost archive (clears nested archive state). */ + navigateToOuterArchiveRoot = (): void => { + this._archiveNestedPath = null; + this.archivePrefix = ''; + this.archiveLoading = true; + this._callbacks.clearPrevTokens(); + void this._fetchArchiveListing().catch(() => { + this.archiveLoading = false; + }); + }; + + /** Navigate up within the archive. If at root, exit the archive or go to parent archive. */ + navigateUpFromArchive = (): void => { + if (!this.archivePrefix) { + if (this._archiveNestedPath) { + // Go back to outer archive root + this._archiveNestedPath = null; + this.archivePrefix = ''; + this.archiveLoading = true; + this._callbacks.clearPrevTokens(); + void this._fetchArchiveListing().catch(() => { + this.archiveLoading = false; + }); + } else { + this.exitArchive(); + } + return; + } + const withoutTrailing = this.archivePrefix.replace(/\/$/, ''); + const lastSlash = withoutTrailing.lastIndexOf('/'); + void this.navigateInArchive(lastSlash === -1 ? '' : withoutTrailing.slice(0, lastSlash + 1)); + }; + + /** Exit the archive and return to the S3 folder that contains it. */ + exitArchive = (): void => { + const s3Prefix = this._previousS3Prefix; + this.reset(); + this._callbacks.onExit(s3Prefix); + }; + + /** Download a file from within the current archive. */ + async downloadFromArchive(internalPath: string): Promise { + if (!this.archiveKey) return; + try { + const res = await this._api.archiveExtract({ + bucket: this._callbacks.getBucket(), + key: this.archiveKey, + path: internalPath, + nestedArchivePath: this._archiveNestedPath ?? undefined + }); + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const filename = internalPath.split('/').filter(Boolean).pop() ?? internalPath; + const anchor = document.createElement('a'); + anchor.href = blobUrl; + anchor.download = filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + setTimeout(() => URL.revokeObjectURL(blobUrl), 10_000); + } catch (err: unknown) { + if (err instanceof StorageError) { + addToast('error', getActionErrorMessage(err)); + } else { + addToast('error', m.storage_download_error_unknown()); + } + } + } + + /** Re-fetch the archive listing. Only call when isInArchive is true. */ + refreshListing = (): void => { + this.archiveLoading = true; + void this._fetchArchiveListing().catch(() => { + this.archiveLoading = false; + }); + }; + + /** Clear all archive state (called from StorageState.syncFromServer). */ + reset = (): void => { + this.archiveKey = null; + this.archivePrefix = ''; + this._archiveNestedPath = null; + this._previousS3Prefix = ''; + this.archiveLoading = false; + this.archiveTooLarge = false; + }; + + // ── Internal methods ────────────────────────────────────────────────── + + /** + * Restore full archive state from a tab snapshot. For use by TabsState only. + * Directly sets internal fields including private ones. + */ + _restoreFullState(params: { + archiveKey: string | null; + archivePrefix: string; + archiveNestedPath: string | null; + previousS3Prefix: string; + archiveLoading: boolean; + archiveTooLarge: boolean; + }): void { + this.archiveKey = params.archiveKey; + this.archivePrefix = params.archivePrefix; + this._archiveNestedPath = params.archiveNestedPath; + this._previousS3Prefix = params.previousS3Prefix; + this.archiveLoading = params.archiveLoading; + this.archiveTooLarge = params.archiveTooLarge; + } + + /** + * Fetch S3 objects for the given prefix (used when exiting archive). + * This is called from StorageState's onExit callback. + */ + async _fetchS3Objects(prefix: string): Promise { + try { + const objects = await this._api.list({ + bucket: this._callbacks.getBucket(), + prefix: prefix ?? '', + pageSize: this._callbacks.getPageSize() + }); + this._callbacks.setPrefix(prefix); + this._callbacks.setObjects(objects); + } catch { + // Fall back to invalidateAll if manual fetch fails + void invalidateAll(); + } finally { + this._callbacks.setLoading(false); + } + } + + /** Fetch archive listing from the server API. */ + private async _fetchArchiveListing(): Promise { + if (!this.archiveKey) { + this.archiveLoading = false; + return; + } + const data = await this._api.archiveListing({ + bucket: this._callbacks.getBucket(), + key: this.archiveKey, + internalPrefix: this.archivePrefix, + nestedArchivePath: this._archiveNestedPath ?? undefined + }); + if (data.tooLarge) { + this.archiveTooLarge = true; + this.archiveLoading = false; + this._callbacks.setObjects({ + objects: [], + hasNextPage: false, + currentPage: 1, + pageSize: null as never + }); + return; + } + this.archiveTooLarge = false; + const prefix = this.archivePrefix || ''; + this._callbacks.setObjects({ + objects: data.entries.map((e) => ({ + key: prefix + e.key, + size: e.size, + lastModified: e.lastModified, + isDirectory: e.isDirectory, + contentType: undefined + })), + hasNextPage: data.hasMore, + currentPage: 1, + pageSize: null as never + }); + this.archiveLoading = false; + } +} diff --git a/src/lib/storage/clipboard-move-disabled.svelte.spec.ts b/src/lib/storage/clipboard-move-disabled.svelte.spec.ts new file mode 100644 index 00000000..ea5f908a --- /dev/null +++ b/src/lib/storage/clipboard-move-disabled.svelte.spec.ts @@ -0,0 +1,47 @@ +// The flags are read once per module in the browser project, so the disabled +// move flag gets its own spec file. +vi.mock('$lib/client/feature-flags.js', () => ({ + storageRestoreTabsEnabled: false, + allowedPageSizes: [25, 50, 100], + defaultPageSize: 25, + maxRecentFiles: 15, + maxEditableFileSize: 5 * 1024 * 1024, + uploadConcurrency: 3, + storageCutCopyEnabled: true, + storagePasteEnabled: true, + storageRenameEnabled: true, + storageMoveEnabled: false +})); + +vi.mock('$lib/storage/connection-store.svelte.js', () => ({ + connectionStore: { activeConnectionId: 'test-connection-id' } +})); + +vi.mock('$lib/stores/toast.svelte.js', () => ({ + addToast: vi.fn() +})); + +vi.mock('$app/navigation', () => ({ + invalidateAll: vi.fn() +})); + +import { describe, it, expect, vi } from 'vitest'; +import { StorageState } from './state.svelte.js'; +import { createMemoryStorageApi } from './api.test-utils.js'; + +describe('drag and drop with moving disabled', () => { + it('ignores drops', () => { + const transfer = vi.fn(); + const state = new StorageState({ + connected: true, + buckets: ['test-bucket'], + api: createMemoryStorageApi({ transfer }) + }); + state.bucket = 'test-bucket'; + + state.performMove('dest/', ['a.txt']); + + expect(state.activeModal).toBeNull(); + expect(transfer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/storage/clipboard.svelte.spec.ts b/src/lib/storage/clipboard.svelte.spec.ts new file mode 100644 index 00000000..e8451d1a --- /dev/null +++ b/src/lib/storage/clipboard.svelte.spec.ts @@ -0,0 +1,577 @@ +vi.mock('$lib/client/feature-flags.js', () => ({ + storageMoveEnabled: true, + storageRestoreTabsEnabled: false, + allowedPageSizes: [25, 50, 100], + defaultPageSize: 25, + maxRecentFiles: 15, + maxEditableFileSize: 5 * 1024 * 1024, + uploadConcurrency: 3, + storageCutCopyEnabled: true, + storagePasteEnabled: true, + storageRenameEnabled: true +})); + +vi.mock('$lib/storage/connection-store.svelte.js', () => ({ + connectionStore: { activeConnectionId: 'test-connection-id' } +})); + +vi.mock('$lib/stores/toast.svelte.js', () => ({ + addToast: vi.fn() +})); + +vi.mock('$app/navigation', () => ({ + invalidateAll: vi.fn() +})); + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StorageState } from './state.svelte.js'; +import type { StorageApi } from './api.js'; +import type { ClipboardData } from './types.js'; +import type { ConflictEntry } from '$lib/components/storage/modals/shared/conflict-types.js'; +import type { PlanItem, TransferBody, TransferPlanBody } from './transfer-types.js'; +import { createMemoryStorageApi, fakeTransfer } from './api.test-utils.js'; +import { makePage } from './state.test-utils.js'; +import { addToast } from '$lib/stores/toast.svelte.js'; + +type ConflictPayload = { + entries: ConflictEntry[]; + refused: Array<{ name: string; reason: string }>; + closeOnly: boolean; +}; + +function makeState(api: StorageApi, clip: ClipboardData | null = null): StorageState { + const state = new StorageState({ connected: true, buckets: ['test-bucket'], api }); + state.bucket = 'test-bucket'; + state.prefix = 'dest/'; + state.objects = makePage(); + state.clipboardState.clipboard = clip; + return state; +} + +function clipboard(action: 'copy' | 'cut', keys: string[], sourceBucket = 'test-bucket') { + return { + action, + keys, + connectionId: 'test-connection-id', + sourceBucket, + sourcePrefix: 'src/', + fileSizes: {} + } satisfies ClipboardData; +} + +/** A plan with the given per-key fields; other keys plan without conflict. */ +function planWith(fields: Record>): StorageApi['planTransfer'] { + return async (body) => + body.source.keys.map((sourceKey) => ({ + sourceKey, + kind: sourceKey.endsWith('/') ? 'folder' : 'file', + destKey: body.dest.prefix + sourceKey.split('/').filter(Boolean).pop(), + conflict: 'none', + ...fields[sourceKey] + })); +} + +function sentBody(transfer: StorageApi['transfer']): TransferBody { + return vi.mocked(transfer).mock.calls[0][0]; +} + +function conflictPayload(state: StorageState): ConflictPayload { + expect(state.activeModal?.type).toBe('resolve-conflicts'); + return (state.activeModal as unknown as { payload: ConflictPayload }).payload; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('paste', () => { + it('sends one request for the whole selection', async () => { + const transfer = vi.fn(fakeTransfer()); + const state = makeState( + createMemoryStorageApi({ transfer }), + clipboard('copy', ['src/a', 'src/b/']) + ); + + await state.clipboardState.paste('dest/'); + + expect(transfer).toHaveBeenCalledTimes(1); + expect(sentBody(transfer)).toMatchObject({ + op: 'copy', + source: { bucket: 'test-bucket' }, + dest: { bucket: 'test-bucket', prefix: 'dest/' }, + items: [{ sourceKey: 'src/a' }, { sourceKey: 'src/b/' }] + }); + }); + + it('keeps both for a copy onto itself without asking', async () => { + const transfer = vi.fn(fakeTransfer()); + const api = createMemoryStorageApi({ + transfer, + planTransfer: planWith({ 'dest/a.txt': { conflict: 'file', selfConflict: true } }) + }); + const state = makeState(api, clipboard('copy', ['dest/a.txt'])); + + await state.clipboardState.paste('dest/'); + + expect(state.activeModal).toBeNull(); + expect(sentBody(transfer).items).toEqual([{ sourceKey: 'dest/a.txt', policy: 'keepBoth' }]); + }); + + it('blocks a second paste while one is running', async () => { + let finish!: () => void; + const running = new Promise((resolve) => (finish = resolve)); + const transfer = vi.fn( + async (body: TransferBody, onEvent: Parameters[1]) => { + await running; + return fakeTransfer()(body, onEvent); + } + ); + const state = makeState(createMemoryStorageApi({ transfer }), clipboard('cut', ['src/a'])); + + const first = state.clipboardState.paste('dest/'); + await vi.waitFor(() => expect(transfer).toHaveBeenCalledTimes(1)); + await state.clipboardState.paste('other/'); + finish(); + await first; + + expect(transfer).toHaveBeenCalledTimes(1); + expect(addToast).toHaveBeenCalledWith('info', expect.stringContaining('still running')); + }); + + it('blocks a drag-move while a paste is running, and a paste while a move runs', async () => { + let finish!: () => void; + const running = new Promise((resolve) => (finish = resolve)); + const transfer = vi.fn( + async (body: TransferBody, onEvent: Parameters[1]) => { + await running; + return fakeTransfer()(body, onEvent); + } + ); + const state = makeState(createMemoryStorageApi({ transfer }), clipboard('cut', ['src/a'])); + + const paste = state.clipboardState.paste('dest/'); + await vi.waitFor(() => expect(transfer).toHaveBeenCalledTimes(1)); + state.performMove('other/', ['src/a']); + expect(state.activeModal).toBeNull(); + finish(); + await paste; + + let finishMove!: () => void; + const moving = new Promise((resolve) => (finishMove = resolve)); + transfer.mockImplementationOnce(async (body, onEvent) => { + await moving; + return fakeTransfer()(body, onEvent); + }); + state.performMove('other/', ['src/b']); + const move = state.confirmMove(); + await vi.waitFor(() => expect(transfer).toHaveBeenCalledTimes(2)); + await state.clipboardState.paste('dest/'); + finishMove(); + await move; + + expect(transfer).toHaveBeenCalledTimes(2); + expect(vi.mocked(addToast).mock.calls.filter(([type]) => type === 'info')).toHaveLength(2); + }); + + it('refuses more than 1000 items up front without a request', async () => { + const planTransfer = vi.fn(); + const keys = Array.from({ length: 1001 }, (_, i) => `src/${i}`); + const state = makeState(createMemoryStorageApi({ planTransfer }), clipboard('copy', keys)); + + await state.clipboardState.paste('dest/'); + + expect(planTransfer).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith('error', expect.stringContaining('1000')); + }); + + it('keeps the destination fixed when the user switches tab during a paste (R1)', async () => { + let releasePlan!: () => void; + const planned = new Promise((resolve) => (releasePlan = resolve)); + const transfer = vi.fn(fakeTransfer()); + const api = createMemoryStorageApi({ + transfer, + planTransfer: async (body) => { + await planned; + return planWith({})(body); + } + }); + const state = makeState(api, clipboard('copy', ['src/a'])); + + const paste = state.clipboardState.paste('dest/'); + state.bucket = 'other-bucket'; + state.prefix = 'elsewhere/'; + releasePlan(); + await paste; + + expect(sentBody(transfer).dest).toEqual({ bucket: 'test-bucket', prefix: 'dest/' }); + }); +}); + +describe('conflict dialog', () => { + const api = () => + createMemoryStorageApi({ + transfer: vi.fn(fakeTransfer()), + planTransfer: planWith({ + 'src/a.txt': { conflict: 'file', suggestedName: 'a (1).txt' }, + 'src/docs/': { conflict: 'folder', suggestedName: 'docs (1)' }, + 'src/dest/': { refused: 'into_itself' } + }) + }); + + it('offers per-kind choices and lists refused items with their reason', async () => { + const state = makeState( + api(), + clipboard('copy', ['src/a.txt', 'src/docs/', 'src/dest/', 'src/new']) + ); + + await state.clipboardState.paste('dest/'); + + const payload = conflictPayload(state); + expect(payload.entries).toEqual([ + expect.objectContaining({ + sourceKey: 'src/a.txt', + kind: 'file', + allowKeepBoth: true, + customName: 'a (1).txt', + destPrefix: 'dest/' + }), + expect.objectContaining({ sourceKey: 'src/docs/', kind: 'folder' }) + ]); + expect(payload.refused).toEqual([ + { name: 'dest', reason: 'A folder cannot be placed inside itself.' } + ]); + expect(payload.closeOnly).toBe(false); + }); + + it('sends the chosen policies in selection order and leaves skipped items out', async () => { + const transferApi = api(); + const state = makeState( + transferApi, + clipboard('copy', ['src/a.txt', 'src/new', 'src/docs/', 'src/dest/']) + ); + await state.clipboardState.paste('dest/'); + const { entries } = conflictPayload(state); + + await state.confirmConflictResolution([ + { ...entries[0], resolution: 'rename', customName: ' b.txt ' }, + { ...entries[1], resolution: 'merge' } + ]); + + expect(sentBody(transferApi.transfer).items).toEqual([ + { sourceKey: 'src/a.txt', policy: 'rename', name: 'b.txt' }, + { sourceKey: 'src/new' }, + { sourceKey: 'src/docs/', policy: 'merge' } + ]); + }); + + it('keeps the dialog open while another transfer runs, then sends on a later confirm', async () => { + const transferApi = api(); + let finish!: () => void; + const running = new Promise((resolve) => (finish = resolve)); + const transfer = vi.mocked(transferApi.transfer); + const state = makeState(transferApi, clipboard('copy', ['src/a.txt'])); + await state.clipboardState.paste('dest/'); + const { entries } = conflictPayload(state); + const chosen = [{ ...entries[0], resolution: 'replace' as const }]; + + // A drag-move starts while the dialog is open. + transfer.mockImplementationOnce(async (body, onEvent) => { + await running; + return fakeTransfer()(body, onEvent); + }); + state.clipboardState.performMove('other/', ['src/b']); + const move = state.clipboardState.confirmMove(); + await vi.waitFor(() => expect(transfer).toHaveBeenCalledTimes(1)); + + await state.confirmConflictResolution(chosen); + expect(transfer).toHaveBeenCalledTimes(1); + expect(addToast).toHaveBeenCalledWith('info', expect.stringContaining('still running')); + + finish(); + await move; + await state.confirmConflictResolution(chosen); + expect(transfer).toHaveBeenCalledTimes(2); + expect(sentBody(transfer).items).toEqual([{ sourceKey: 'src/b' }]); + expect(transfer.mock.calls[1][0].items).toEqual([ + { sourceKey: 'src/a.txt', policy: 'replace' } + ]); + }); + + it('sends nothing when every conflict is skipped', async () => { + const transferApi = api(); + const state = makeState(transferApi, clipboard('copy', ['src/a.txt'])); + await state.clipboardState.paste('dest/'); + const { entries } = conflictPayload(state); + + await state.confirmConflictResolution([{ ...entries[0], resolution: 'skip' }]); + + expect(transferApi.transfer).not.toHaveBeenCalled(); + }); + + it('checks a new name with a one-item plan under that name', async () => { + const planTransfer = vi.fn(async (body: TransferPlanBody) => + body.names?.['src/a.txt'] === 'taken.txt' + ? planWith({ 'src/a.txt': { conflict: 'file' } })(body) + : planWith({})(body) + ); + const transferApi = api(); + const state = makeState(transferApi, clipboard('copy', ['src/a.txt', 'src/docs/'])); + await state.clipboardState.paste('dest/'); + const { entries } = conflictPayload(state); + transferApi.planTransfer = planTransfer; + + const check = (customName: string) => + state.checkConflictRename({ ...entries[0], resolution: 'rename', customName }); + + await expect(check(' taken.txt ')).resolves.toBe(false); + await expect(check('free.txt')).resolves.toBe(true); + expect(planTransfer.mock.calls[0][0]).toEqual({ + op: 'copy', + source: { bucket: 'test-bucket', keys: ['src/a.txt'] }, + dest: { bucket: 'test-bucket', prefix: 'dest/' }, + names: { 'src/a.txt': 'taken.txt' } + }); + }); + + it('does not accept a new name the plan refuses', async () => { + const transferApi = api(); + const state = makeState(transferApi, clipboard('copy', ['src/a.txt'])); + await state.clipboardState.paste('dest/'); + const { entries } = conflictPayload(state); + transferApi.planTransfer = planWith({ 'src/a.txt': { refused: 'overlaps_source' } }); + + await expect( + state.checkConflictRename({ ...entries[0], resolution: 'rename', customName: 'x' }) + ).resolves.toBe(false); + }); + + it('reports a name taken by an item pasted without a conflict, without asking the server', async () => { + const transferApi = api(); + const state = makeState(transferApi, clipboard('copy', ['src/a.txt', 'src/c.txt', 'src/d/'])); + await state.clipboardState.paste('dest/'); + const { entries } = conflictPayload(state); + const planTransfer = vi.fn(planWith({})); + transferApi.planTransfer = planTransfer; + + const check = (customName: string) => + state.checkConflictRename({ ...entries[0], resolution: 'rename', customName }); + + await expect(check('c.txt')).resolves.toBe(false); + expect(planTransfer).not.toHaveBeenCalled(); + // A file may take the name of a folder: `d` and `d/` are different keys. + await expect(check('d')).resolves.toBe(true); + }); + + it('toasts and rethrows when the name check fails', async () => { + const transferApi = api(); + const state = makeState(transferApi, clipboard('copy', ['src/a.txt'])); + await state.clipboardState.paste('dest/'); + const { entries } = conflictPayload(state); + transferApi.planTransfer = async () => { + throw new Error('offline'); + }; + + await expect( + state.checkConflictRename({ ...entries[0], resolution: 'rename', customName: 'x' }) + ).rejects.toThrow('offline'); + expect(addToast).toHaveBeenCalledWith('error', expect.any(String)); + }); + + it('only offers Close when every item is refused', async () => { + const state = makeState(api(), clipboard('copy', ['src/dest/'])); + + await state.clipboardState.paste('dest/'); + + expect(conflictPayload(state)).toMatchObject({ entries: [], closeOnly: true }); + }); +}); + +describe('clipboard after a cut', () => { + it('copies moved folders from their new location, still as folders (F2)', async () => { + const state = makeState(createMemoryStorageApi(), clipboard('cut', ['src/docs/', 'src/a.txt'])); + + await state.clipboardState.paste('dest/'); + + expect(state.clipboard).toMatchObject({ + action: 'copy', + keys: ['dest/docs/', 'dest/a.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: 'dest/' + }); + }); + + it('keeps unmoved items cut at the source and drops the moved ones', async () => { + const api = createMemoryStorageApi({ + transfer: fakeTransfer({ 'src/b.txt': { status: 'failed', copied: 0 } }) + }); + const state = makeState(api, clipboard('cut', ['src/a.txt', 'src/b.txt'])); + + await state.clipboardState.paste('dest/'); + + expect(state.clipboard).toMatchObject({ action: 'cut', keys: ['src/b.txt'] }); + }); + + it('points a partly moved item at its destination copy', async () => { + const api = createMemoryStorageApi({ + transfer: fakeTransfer({ 'src/docs/': { status: 'partial' } }) + }); + const state = makeState(api, clipboard('cut', ['src/docs/'])); + + await state.clipboardState.paste('dest/'); + + expect(state.clipboard).toMatchObject({ action: 'copy', keys: ['dest/docs/'] }); + }); + + it('keeps a partial move whose copies failed cut, so a paste retries the move', async () => { + const api = createMemoryStorageApi({ + transfer: fakeTransfer({ 'src/docs/': { status: 'partial', deletedOriginals: 0 } }) + }); + const state = makeState(api, clipboard('cut', ['src/docs/'])); + + await state.clipboardState.paste('dest/'); + + expect(state.clipboard).toMatchObject({ action: 'cut', keys: ['src/docs/'] }); + }); + + it('leaves a clipboard alone that was replaced during the move', async () => { + const state = makeState(createMemoryStorageApi(), clipboard('cut', ['src/a.txt'])); + const replacement = clipboard('copy', ['other.txt']); + const api = state.api; + const transfer = api.transfer; + api.transfer = async (body, onEvent) => { + state.clipboardState.clipboard = replacement; + return transfer(body, onEvent); + }; + + await state.clipboardState.paste('dest/'); + + expect(state.clipboard).toEqual(replacement); + }); +}); + +describe('drag and drop', () => { + it('moves from the drag source bucket into the bucket of the tab it is dropped on (R5)', async () => { + const transfer = vi.fn(fakeTransfer()); + const state = makeState(createMemoryStorageApi({ transfer })); + + state.performMove('target/', ['a.txt'], 'tab-bucket', { + connectionId: 'test-connection-id', + bucket: 'drag-bucket' + }); + await state.confirmMove(); + + expect(sentBody(transfer)).toMatchObject({ + op: 'move', + source: { bucket: 'drag-bucket' }, + dest: { bucket: 'tab-bucket', prefix: 'target/' } + }); + }); + + it('moves an item to the same path in another bucket', () => { + const state = makeState(createMemoryStorageApi()); + + state.performMove('', ['file.txt'], 'other-bucket'); + + expect(state.activeModal?.type).toBe('confirm-move'); + }); +}); + +describe('feedback', () => { + it('shows exactly one toast with counts and a capped name list', async () => { + const keys = ['src/1', 'src/2', 'src/3', 'src/4', 'src/5', 'src/6']; + const api = createMemoryStorageApi({ + transfer: fakeTransfer({ + 'src/2': { status: 'failed', copied: 0 }, + 'src/3': { status: 'refused', reason: 'conflict', copied: 0 }, + 'src/4': { status: 'partial' }, + 'src/5': { status: 'failed', copied: 0 }, + 'src/6': { status: 'cancelled', copied: 0 } + }) + }); + const state = makeState(api, clipboard('cut', keys)); + + await state.clipboardState.paste('dest/'); + + expect(addToast).toHaveBeenCalledTimes(1); + const [type, message] = vi.mocked(addToast).mock.calls[0]; + expect(type).toBe('warning'); + expect(message).toContain('1 done, 1 partly done, 2 failed, 1 refused, 1 cancelled'); + expect(message).toContain('2: failed'); + expect(message).toContain('3: An item with this name already exists.'); + expect(message).toContain('4: only partly transferred'); + expect(message).toContain('+ 2 more'); + }); + + it('reports items without any result as unknown, never as success', async () => { + const keys = ['src/1', 'src/2', 'src/3', 'src/4', 'src/5']; + // The stream drops after two items and the job cannot be read back either. + const transfer: StorageApi['transfer'] = async (body, onEvent) => { + await fakeTransfer()({ ...body, items: body.items.slice(0, 2) }, (event) => { + if (event.type !== 'complete') onEvent(event); + }); + return { complete: false }; + }; + const state = makeState(createMemoryStorageApi({ transfer }), clipboard('cut', keys)); + + await state.clipboardState.paste('dest/'); + + expect(addToast).toHaveBeenCalledTimes(1); + const [type, message] = vi.mocked(addToast).mock.calls[0]; + expect(type).toBe('warning'); + expect(message).toContain('2 done, 3 with unknown outcome.'); + expect(message).toContain('3: outcome unknown'); + expect(state.operations.at(-1)?.status).toBe('error'); + // Only the items known to be moved leave the clipboard. + expect(state.clipboard).toMatchObject({ action: 'cut', keys: ['src/3', 'src/4', 'src/5'] }); + }); + + it('reports a stream that drops before the first result', async () => { + const api = createMemoryStorageApi({ transfer: async () => ({ complete: false }) }); + const state = makeState(api, clipboard('copy', ['src/a', 'src/b'])); + + await state.clipboardState.paste('dest/'); + + expect(addToast).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('2 with unknown outcome.'), + 8000 + ); + expect(state.operations.at(-1)?.status).toBe('error'); + }); + + it('reads the results back from the job when the stream ends without complete (#21)', async () => { + const readTransferJob = vi + .fn() + .mockResolvedValueOnce({ status: 'running' }) + .mockResolvedValueOnce({ + status: 'done', + items: [ + { + sourceKey: 'src/a.txt', + destRoot: 'dest/a.txt', + status: 'done', + copied: 1, + failed: [], + deletedOriginals: 0 + } + ] + }); + vi.useFakeTimers(); + try { + const api = createMemoryStorageApi({ + transfer: async () => ({ complete: false }), + readTransferJob + }); + const state = makeState(api, clipboard('copy', ['src/a.txt'])); + + const paste = state.clipboardState.paste('dest/'); + await vi.runAllTimersAsync(); + await paste; + + expect(readTransferJob).toHaveBeenCalledTimes(2); + expect(addToast).toHaveBeenCalledWith('success', expect.stringContaining('1 item pasted')); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/storage/clipboard.svelte.ts b/src/lib/storage/clipboard.svelte.ts new file mode 100644 index 00000000..af374884 --- /dev/null +++ b/src/lib/storage/clipboard.svelte.ts @@ -0,0 +1,545 @@ +import { SvelteMap, SvelteSet } from 'svelte/reactivity'; +import * as m from '$lib/paraglide/messages.js'; +import { addToast } from '$lib/stores/toast.svelte.js'; +import type { StoragePage, ClipboardData, ModalType } from '$lib/storage/types.js'; +import type { ConflictEntry } from '$lib/components/storage/modals/shared/conflict-types.js'; +import { keyToName, parentPrefix } from '$lib/storage/utils.js'; +import { StorageError, getActionErrorMessage } from './errors.js'; +import { pageUnloading } from './operations.svelte.js'; +import { storageMoveEnabled } from '$lib/client/feature-flags.js'; +import { OperationsState } from './operations.svelte.js'; +import type { StorageApi } from './api.js'; +import { MAX_TRANSFER_ITEMS, type PlanItem, type TransferItemRequest } from './transfer-types.js'; +import { refusalMessage, runTransfer, toastTransferResults, type ItemOutcome } from './transfer.js'; + +// ── Internal types ────────────────────────────────────────────────────────── + +/** Where a copy or move goes, fixed when the user acts (never re-read later). */ +interface TransferTarget { + op: 'copy' | 'move'; + sourceBucket: string; + destBucket: string; + destPrefix: string; + keys: string[]; + /** File sizes by key, for recent-file tracking. */ + fileSizes: Record; + /** The clipboard a cut paste came from; it is updated after the move. */ + clipboard: ClipboardData | null; + /** The user's action, for the wording of operations and toasts. */ + action: 'paste' | 'move'; +} + +/** A transfer waiting for the conflict dialog. */ +interface PendingTransfer extends TransferTarget { + /** Items sent without asking: no conflict, or a copy onto itself (kept as both). */ + ready: TransferItemRequest[]; +} + +/** A drag-and-drop move waiting for confirmation. */ +interface PendingMove extends TransferTarget { + items: Array<{ key: string; name: string; isDirectory: boolean; size?: number }>; +} + +/** Where dragged items come from, as carried in the drag data. */ +export interface DragSource { + connectionId: string | null; + bucket: string; +} + +// ── Callbacks interface ────────────────────────────────────────────────────── + +export interface ClipboardStateCallbacks { + getBucket: () => string; + getPrefix: () => string; + getConnectionId: () => string | null; + getObjects: () => StoragePage; + getSelectedKeys: () => Iterable; + openModal: (type: ModalType, payload: unknown) => void; + closeModal: () => void; + refresh: () => void; + invalidateSourceTabs: (prefix: string) => void; + recordFileVisit: (bucket: string, key: string, size: number) => void; + removeFiles: (bucket: string, keys: string[]) => void; + clearSelection: () => void; +} + +// ── ClipboardState ─────────────────────────────────────────────────────────── + +export class ClipboardState { + /** Current clipboard content (null when empty). */ + clipboard = $state(null); + + private _pendingTransfer: PendingTransfer | null = null; + /** + * A paste or drag-move is being planned or run. A second one would race it, + * e.g. move the same items twice, so it is refused until the first ends. + */ + private _transfersRunning = 0; + + private get _transferRunning(): boolean { + return this._transfersRunning > 0; + } + private _pendingMove: PendingMove | null = null; + + private _api: StorageApi; + private _operations: OperationsState; + private _callbacks: ClipboardStateCallbacks; + + constructor(api: StorageApi, operations: OperationsState, callbacks: ClipboardStateCallbacks) { + this._api = api; + this._operations = operations; + this._callbacks = callbacks; + } + + /** Whether the clipboard can be pasted in the active connection. */ + get canPaste(): boolean { + return ( + this.clipboard !== null && + this.clipboard.keys.length > 0 && + this.clipboard.connectionId === this._callbacks.getConnectionId() + ); + } + + // ── Cut / Copy ───────────────────────────────────────────────────────────── + + /** Returns true when `key` is in the clipboard with action='cut' and the bucket matches. */ + isCutKey(key: string, bucket: string): boolean { + return ( + this.clipboard?.action === 'cut' && + this.clipboard.sourceBucket === bucket && + this.clipboard.keys.includes(key) + ); + } + + /** Set clipboard with cut action. Caller should check storageCutCopyEnabled before calling. */ + cut(selectedKeys: string[], objects: StoragePage, bucket: string, prefix: string): void { + if (selectedKeys.length === 0) return; + this.clipboard = this._clipboardFor('cut', selectedKeys, objects, bucket, prefix); + addToast('info', m.storage_action_cut_success({ count: selectedKeys.length })); + } + + /** Set clipboard with copy action. Caller should check storageCutCopyEnabled before calling. */ + copy(selectedKeys: string[], objects: StoragePage, bucket: string, prefix: string): void { + if (selectedKeys.length === 0) return; + this.clipboard = this._clipboardFor('copy', selectedKeys, objects, bucket, prefix); + addToast('info', m.storage_action_copy_success({ count: selectedKeys.length })); + } + + // ── Paste ────────────────────────────────────────────────────────────────── + + /** + * Paste the clipboard into `destPrefix` of the current bucket. Caller should + * check storagePasteEnabled and that the clipboard is non-empty first. + */ + async paste(destPrefix: string): Promise { + const clipboard = this.clipboard; + if (!clipboard) return; + if (clipboard.connectionId !== this._callbacks.getConnectionId()) { + addToast('warning', m.storage_transfer_other_connection()); + return; + } + if (this._transferRunning) { + addToast('info', m.storage_transfer_running()); + return; + } + await this._whileTransferring(() => + this._plan({ + op: clipboard.action === 'cut' ? 'move' : 'copy', + sourceBucket: clipboard.sourceBucket, + destBucket: this._callbacks.getBucket(), + destPrefix, + keys: clipboard.keys, + fileSizes: clipboard.fileSizes, + clipboard: clipboard.action === 'cut' ? clipboard : null, + action: 'paste' + }) + ); + } + + private async _whileTransferring(run: () => Promise): Promise { + this._transfersRunning++; + try { + await run(); + } finally { + this._transfersRunning--; + } + } + + // ── Move (drag-and-drop) ─────────────────────────────────────────────────── + + /** + * Validate a drag-and-drop move and open the confirmation dialog. `source` is + * where the dragged items come from; it defaults to the current bucket. + */ + performMove = ( + destPrefix: string, + keys?: string[], + destBucket = this._callbacks.getBucket(), + source: DragSource = { + connectionId: this._callbacks.getConnectionId(), + bucket: this._callbacks.getBucket() + } + ): void => { + if (!storageMoveEnabled) return; + if (source.connectionId !== this._callbacks.getConnectionId()) { + addToast('warning', m.storage_transfer_other_connection()); + return; + } + if (this._transferRunning) { + addToast('info', m.storage_transfer_running()); + return; + } + const moveKeys = keys ?? [...this._callbacks.getSelectedKeys()]; + // A drop into the folder the items already live in does nothing. + if ( + moveKeys.length === 0 || + (source.bucket === destBucket && moveKeys.every((key) => parentPrefix(key) === destPrefix)) + ) { + return; + } + + const objects = this._callbacks.getObjects(); + const fileSizes: Record = {}; + const items = moveKeys.map((key) => { + const obj = objects.objects.find((o) => o.key === key); + const size = obj && !obj.isDirectory ? obj.size : undefined; + if (size !== undefined) fileSizes[key] = size; + return { key, name: keyToName(key), isDirectory: key.endsWith('/'), size }; + }); + + this._pendingMove = { + op: 'move', + sourceBucket: source.bucket, + destBucket, + destPrefix, + keys: moveKeys, + fileSizes, + clipboard: null, + action: 'move', + items + }; + this._callbacks.openModal('confirm-move', { keys: moveKeys, destPrefix, items }); + }; + + confirmMove = async (): Promise => { + const pending = this._pendingMove; + if (!pending) return; + this._pendingMove = null; + this._callbacks.closeModal(); + if (this._transferRunning) { + addToast('info', m.storage_transfer_running()); + return; + } + await this._whileTransferring(() => this._plan(pending)); + }; + + cancelMove = (): void => { + this._pendingMove = null; + this._callbacks.closeModal(); + }; + + // ── Conflict resolution ──────────────────────────────────────────────────── + + confirmConflictResolution = async (entries: ConflictEntry[]): Promise => { + const pending = this._pendingTransfer; + if (!pending) return; + // Another transfer started while the dialog was open: keep the dialog and its + // choices, so the user can confirm again once that one has finished. + if (this._transferRunning) { + addToast('info', m.storage_transfer_running()); + return; + } + this._pendingTransfer = null; + this._callbacks.closeModal(); + + const chosen: TransferItemRequest[] = []; + for (const entry of entries) { + if (!entry.sourceKey || !entry.conflict || entry.resolution === null) continue; + if (entry.resolution === 'skip') continue; + if (entry.resolution === 'rename') { + chosen.push({ + sourceKey: entry.sourceKey, + policy: 'rename', + name: entry.customName.trim() + }); + } else { + chosen.push({ sourceKey: entry.sourceKey, policy: entry.resolution }); + } + } + // Keep the request in the order of the selection. + const order = new SvelteMap(pending.keys.map((key, index) => [key, index])); + const items = [...pending.ready, ...chosen].sort( + (a, b) => order.get(a.sourceKey)! - order.get(b.sourceKey)! + ); + if (items.length === 0) return; + await this._whileTransferring(() => this._execute(pending, items)); + }; + + /** + * Whether the name chosen for a conflict entry is free at the destination, + * planned by the server under the rules the transfer itself applies. + * Throws when the check cannot be made, so the dialog lets the user retry. + */ + checkConflictRename = async (entry: ConflictEntry): Promise => { + const pending = this._pendingTransfer; + if (!pending || !entry.sourceKey) throw new Error('No transfer waiting for this entry'); + // Items without a conflict go in the same request under their own names; the + // one-item plan below cannot see them, so a rename onto one is caught here. + const name = entry.customName.trim(); + const suffix = entry.sourceKey.endsWith('/') ? '/' : ''; + const takenByReady = pending.ready.some( + (item) => + item.policy === undefined && + keyToName(item.sourceKey) + (item.sourceKey.endsWith('/') ? '/' : '') === name + suffix + ); + if (takenByReady) return false; + let plan: PlanItem[]; + try { + plan = await this._api.planTransfer({ + op: pending.op, + source: { bucket: pending.sourceBucket, keys: [entry.sourceKey] }, + dest: { bucket: pending.destBucket, prefix: pending.destPrefix }, + names: { [entry.sourceKey]: name } + }); + } catch (err) { + this._toastRequestError(pending.action, err); + throw err; + } + const item = plan[0]; + return !!item && !item.refused && item.conflict === 'none'; + }; + + cancelConflictResolution = (): void => { + this._pendingTransfer = null; + this._callbacks.closeModal(); + }; + + // ── Clipboard cleanup ────────────────────────────────────────────────────── + + /** Remove deleted keys from the clipboard after a delete operation. */ + removeDeletedKeys(bucket: string, keys: string[]): void { + if (!this.clipboard || this.clipboard.sourceBucket !== bucket) return; + const remainingKeys = this.clipboard.keys.filter((k) => !keys.includes(k)); + if (remainingKeys.length === this.clipboard.keys.length) return; + this.clipboard = + remainingKeys.length === 0 ? null : this._withKeys(this.clipboard, remainingKeys); + } + + // ── Private ──────────────────────────────────────────────────────────────── + + private _clipboardFor( + action: ClipboardData['action'], + keys: string[], + objects: StoragePage, + bucket: string, + prefix: string + ): ClipboardData { + const fileSizes: Record = {}; + for (const obj of objects.objects) { + if (keys.includes(obj.key) && !obj.isDirectory) fileSizes[obj.key] = obj.size; + } + return { + action, + keys, + connectionId: this._callbacks.getConnectionId(), + sourceBucket: bucket, + sourcePrefix: prefix, + fileSizes + }; + } + + private _withKeys(clipboard: ClipboardData, keys: string[]): ClipboardData { + const fileSizes: Record = {}; + for (const key of keys) { + if (clipboard.fileSizes[key] !== undefined) fileSizes[key] = clipboard.fileSizes[key]; + } + return { ...clipboard, keys, fileSizes }; + } + + /** + * Ask the server what the transfer would do, then either run it or let the + * user resolve conflicts first. A copy onto itself is kept as both without + * asking, so Ctrl+C/Ctrl+V gives `file (1).txt`. + */ + private async _plan(target: TransferTarget): Promise { + if (target.keys.length > MAX_TRANSFER_ITEMS) { + addToast('error', m.storage_transfer_too_many({ max: MAX_TRANSFER_ITEMS })); + return; + } + + let plan: PlanItem[]; + try { + plan = await this._api.planTransfer({ + op: target.op, + source: { bucket: target.sourceBucket, keys: target.keys }, + dest: { bucket: target.destBucket, prefix: target.destPrefix } + }); + } catch (err) { + this._toastRequestError(target.action, err); + return; + } + + const ready: TransferItemRequest[] = []; + const conflicts: PlanItem[] = []; + const refused: PlanItem[] = []; + for (const item of plan) { + if (item.refused) refused.push(item); + else if (item.selfConflict) ready.push({ sourceKey: item.sourceKey, policy: 'keepBoth' }); + else if (item.conflict !== 'none') conflicts.push(item); + else ready.push({ sourceKey: item.sourceKey }); + } + + if (conflicts.length === 0 && refused.length === 0) { + await this._execute(target, ready); + return; + } + + this._pendingTransfer = { ...target, ready }; + const entries: ConflictEntry[] = conflicts.map((item) => ({ + id: item.sourceKey, + originalName: keyToName(item.sourceKey), + conflict: true, + resolution: null, + customName: item.suggestedName ?? keyToName(item.sourceKey), + renameState: 'idle', + sourceKey: item.sourceKey, + kind: item.kind, + allowKeepBoth: true, + destPrefix: target.destPrefix + })); + this._callbacks.openModal('resolve-conflicts', { + entries, + refused: refused.map((item) => ({ + name: keyToName(item.sourceKey), + reason: refusalMessage(item.refused!) + })), + closeOnly: conflicts.length === 0 && ready.length === 0, + confirmLabel: target.action === 'paste' ? m.storage_action_paste() : m.storage_action_move() + }); + } + + private async _execute(target: TransferTarget, items: TransferItemRequest[]): Promise { + if (items.length === 0) return; + const names = items.map((item) => keyToName(item.sourceKey)); + const label = + target.action === 'paste' + ? names.length === 1 + ? `${m.storage_operation_paste_one({ count: 1 })}: ${names[0]}` + : `${m.storage_operation_paste_other({ count: names.length })}: ${names[0]} ${m.storage_transfer_more({ count: names.length - 1 })}` + : names.length === 1 + ? `${m.storage_operation_move_one({ count: 1 })}: ${names[0]}` + : `${m.storage_operation_move_other({ count: names.length })}: ${names[0]} ${m.storage_transfer_more({ count: names.length - 1 })}`; + + let results: ItemOutcome[]; + try { + results = await runTransfer({ + api: this._api, + operations: this._operations, + body: { + op: target.op, + source: { bucket: target.sourceBucket }, + dest: { bucket: target.destBucket, prefix: target.destPrefix }, + items + }, + label, + type: target.action, + destPath: `${target.destBucket}/${target.destPrefix}` + }); + } catch (err) { + if (!pageUnloading) this._toastRequestError(target.action, err); + return; + } + + toastTransferResults(target.action, results); + this._recordFiles(target, results); + if (target.clipboard) this._updateClipboardAfterCut(target, results); + if (target.op === 'move' && !target.clipboard) this._callbacks.clearSelection(); + this._refreshAffected(target, results); + } + + private _toastRequestError(action: 'paste' | 'move', err: unknown): void { + addToast( + 'error', + err instanceof StorageError + ? getActionErrorMessage(err) + : action === 'paste' + ? m.storage_action_paste_error() + : m.storage_action_move_error() + ); + } + + /** Keep recent files in step: moved files leave their old key, copies are visited. */ + private _recordFiles(target: TransferTarget, results: ItemOutcome[]): void { + const landed = results.filter( + (item) => + (item.status === 'done' || item.status === 'partial') && + item.destRoot && + !item.sourceKey.endsWith('/') + ); + if (target.op === 'move') { + const moved = landed.filter((item) => item.status === 'done').map((item) => item.sourceKey); + if (moved.length > 0) this._callbacks.removeFiles(target.sourceBucket, moved); + } + for (const item of landed) { + const size = target.fileSizes[item.sourceKey]; + if (size) this._callbacks.recordFileVisit(target.destBucket, item.destRoot!, size); + } + } + + /** + * After a cut is pasted: if every item moved, the clipboard copies them from + * their new location, so a second paste cannot move them again. Otherwise the + * moved items leave the clipboard and the rest stay cut, so another paste + * retries exactly those. A clipboard replaced in the meantime is left alone. + */ + private _updateClipboardAfterCut(target: TransferTarget, results: ItemOutcome[]): void { + const clipboard = target.clipboard!; + if (this.clipboard !== clipboard) return; + // Moved means originals were deleted: the destination is then the only complete copy. + // A `partial` whose copies failed kept every original and stays cut, so a paste retries it. + const moved = results.filter( + (item) => + (item.status === 'done' || item.status === 'partial') && + item.destRoot && + item.deletedOriginals > 0 + ); + if (moved.length === 0) return; + const movedKeys = new SvelteSet(moved.map((item) => item.sourceKey)); + const unmoved = clipboard.keys.filter((key) => !movedKeys.has(key)); + + if (unmoved.length > 0) { + this.clipboard = this._withKeys(clipboard, unmoved); + return; + } + const fileSizes: Record = {}; + for (const item of moved) { + const size = clipboard.fileSizes[item.sourceKey]; + if (size !== undefined) fileSizes[item.destRoot!] = size; + } + this.clipboard = { + action: 'copy', + keys: moved.map((item) => item.destRoot!), + connectionId: clipboard.connectionId, + sourceBucket: target.destBucket, + sourcePrefix: target.destPrefix, + fileSizes + }; + } + + /** Refresh the current view only if it shows an affected folder; mark other tabs stale. */ + private _refreshAffected(target: TransferTarget, results: ItemOutcome[]): void { + const affected = new SvelteSet([`${target.destBucket}\n${target.destPrefix}`]); + if (target.op === 'move') { + for (const item of results) { + if (item.deletedOriginals > 0) { + affected.add(`${target.sourceBucket}\n${parentPrefix(item.sourceKey)}`); + } + } + } + const prefixes = new SvelteSet([...affected].map((entry) => entry.split('\n')[1])); + for (const prefix of prefixes) this._callbacks.invalidateSourceTabs(prefix); + if (affected.has(`${this._callbacks.getBucket()}\n${this._callbacks.getPrefix()}`)) { + this._callbacks.refresh(); + } + } +} diff --git a/src/lib/storage/connection-id-header.spec.ts b/src/lib/storage/connection-id-header.spec.ts new file mode 100644 index 00000000..5773b94d --- /dev/null +++ b/src/lib/storage/connection-id-header.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { STORAGE_CONNECTION_ID_HEADER, type SavedConnection } from './connection-id-header.js'; + +describe('STORAGE_CONNECTION_ID_HEADER', () => { + it('exports the expected header name', () => { + expect(STORAGE_CONNECTION_ID_HEADER).toBe('x-storage-connection-id'); + }); +}); + +describe('SavedConnection type', () => { + it('is structurally compatible with a valid connection object', () => { + const conn: SavedConnection = { + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'My S3', + host: 's3.example.com', + port: 443, + type: 's3', + region: { name: 'us-east-1' }, + credentials: { accessKey: 'AKID', secretKey: 'secret' } + }; + expect(conn.id).toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(conn.type).toBe('s3'); + }); + + it('allows optional port and credentials', () => { + const conn: SavedConnection = { + id: '550e8400-e29b-41d4-a716-446655440001', + name: 'Minimal', + host: 'minio.example.com', + port: null, + type: 's3', + region: { name: 'eu-central-1' } + }; + expect(conn.host).toBe('minio.example.com'); + expect(conn.port).toBeNull(); + }); +}); diff --git a/src/lib/storage/connection-id-header.ts b/src/lib/storage/connection-id-header.ts new file mode 100644 index 00000000..a7302d1c --- /dev/null +++ b/src/lib/storage/connection-id-header.ts @@ -0,0 +1,18 @@ +/** + * HTTP header name used to pass the S3 connection UUID from the browser to + * the backend API endpoints. Client-safe — no server-only code imported here. + * + * The server middleware looks up the connection by this ID and the + * authenticated user ID, then decrypts the stored credentials. + */ +export const STORAGE_CONNECTION_ID_HEADER = 'x-storage-connection-id'; + +export interface SavedConnection { + id: string; + name: string; + host: string; + port: number | null; + type: 's3'; + region: { name: string }; + credentials?: { accessKey: string; secretKey: string }; +} diff --git a/src/lib/storage/connection-storage.spec.ts b/src/lib/storage/connection-storage.spec.ts deleted file mode 100644 index 7d662095..00000000 --- a/src/lib/storage/connection-storage.spec.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { - saveConnectionLocally, - updateConnectionLocally, - loadConnectionLocally, - loadAllConnectionsLocally, - removeConnectionLocally, - removeConnectionById, - loadConnectionById -} from '$lib/storage/connection-storage.js'; -import type { StoredConnection, SavedConnection } from '$lib/storage/connection-storage.js'; - -const STORAGE_KEY = 'stackable_storage_connections'; - -// localStorage is unavailable in the Node (server) test environment — stub it. -const store: Record = {}; -vi.stubGlobal('localStorage', { - getItem: (key: string) => store[key] ?? null, - setItem: (key: string, value: string) => { - store[key] = value; - }, - removeItem: (key: string) => { - delete store[key]; - }, - clear: () => { - for (const k of Object.keys(store)) delete store[k]; - } -}); - -// crypto.randomUUID is available in Node 15+ — ensure it is present. -if (!globalThis.crypto) { - const { webcrypto } = await import('crypto'); - vi.stubGlobal('crypto', webcrypto); -} - -function makeConn( - overrides: Partial> & { id?: string } = {} -): SavedConnection { - return { - id: overrides.id ?? crypto.randomUUID(), - type: 's3', - host: 'minio.example.com', - port: 9000, - tls: { verification: 'Full' }, - accessStyle: 'Path', - region: { name: 'us-east-1' }, - credentials: { accessKey: 'AKIA123', secretKey: 'secret' }, - ...overrides - } as SavedConnection; -} - -describe('connection-storage', () => { - beforeEach(() => { - localStorage.clear(); - }); - - describe('saveConnectionLocally', () => { - it('stores a connection and assigns the given id', () => { - const conn = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - saveConnectionLocally(conn); - const stored = loadAllConnectionsLocally(); - expect(stored).toHaveLength(1); - expect(stored[0].id).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); - }); - - it('generates a uuid when id is not set', () => { - const conn = makeConn(); - // Simulate missing id (e.g. from legacy code path) by deleting it - const connWithoutId = { ...conn } as Partial; - delete connWithoutId.id; - saveConnectionLocally(connWithoutId as StoredConnection); - const stored = loadAllConnectionsLocally(); - expect(stored[0].id).toBeDefined(); - expect(stored[0].id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ - ); - }); - - it('appends to the end (most recently used)', () => { - const a = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - const b = makeConn({ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }); - saveConnectionLocally(a); - saveConnectionLocally(b); - const stored = loadAllConnectionsLocally(); - expect(stored[stored.length - 1].id).toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); - }); - - it('replaces an existing connection with the same id and moves it to end', () => { - const conn = makeConn({ - id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - region: { name: 'us-east-1' } - }); - saveConnectionLocally(conn); - const updated = makeConn({ - id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - region: { name: 'eu-west-1' } - }); - saveConnectionLocally(updated); - const stored = loadAllConnectionsLocally(); - expect(stored).toHaveLength(1); - expect(stored[0].region.name).toBe('eu-west-1'); - }); - - it('deduplicates by host+port+accessKey when no id is supplied', () => { - // First save — assigns a UUID - const connWithoutId = { - type: 's3' as const, - host: 'minio.example.com', - port: 9000, - tls: { verification: 'Full' as const }, - accessStyle: 'Path' as const, - region: { name: 'us-east-1' }, - credentials: { accessKey: 'AKIA123', secretKey: 'secret' } - }; - saveConnectionLocally(connWithoutId); - const afterFirst = loadAllConnectionsLocally(); - expect(afterFirst).toHaveLength(1); - const assignedId = afterFirst[0].id; - - // Second save with same identity but no id — should reuse the existing entry - saveConnectionLocally({ ...connWithoutId, region: { name: 'eu-west-1' } }); - const afterSecond = loadAllConnectionsLocally(); - expect(afterSecond).toHaveLength(1); - expect(afterSecond[0].id).toBe(assignedId); - expect(afterSecond[0].region.name).toBe('eu-west-1'); - }); - }); - - describe('updateConnectionLocally', () => { - it('updates an existing connection in-place by id', () => { - const a = makeConn({ - id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - region: { name: 'us-east-1' } - }); - const b = makeConn({ - id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - region: { name: 'eu-west-1' } - }); - saveConnectionLocally(a); - saveConnectionLocally(b); - - updateConnectionLocally('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', { - region: { name: 'ap-south-1' } - }); - - const stored = loadAllConnectionsLocally(); - expect(stored).toHaveLength(2); - // Preserved position (first) - expect(stored[0].id).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); - expect(stored[0].region.name).toBe('ap-south-1'); - // Second connection unchanged - expect(stored[1].id).toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); - expect(stored[1].region.name).toBe('eu-west-1'); - }); - - it('is a no-op when the id does not exist', () => { - const a = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - saveConnectionLocally(a); - - updateConnectionLocally('00000000-0000-0000-0000-000000000000', { - region: { name: 'us-west-2' } - }); - - const stored = loadAllConnectionsLocally(); - expect(stored).toHaveLength(1); - expect(stored[0].region.name).toBe(a.region.name); - }); - - it('preserves the id even when data omits it', () => { - const conn = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - saveConnectionLocally(conn); - updateConnectionLocally('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', { name: 'Prod' }); - const stored = loadAllConnectionsLocally(); - expect(stored[0].id).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); - expect(stored[0].name).toBe('Prod'); - }); - }); - - describe('loadConnectionLocally', () => { - it('returns null when nothing is stored', () => { - expect(loadConnectionLocally()).toBeNull(); - }); - - it('returns the last (most recently used) connection', () => { - const a = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - const b = makeConn({ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }); - saveConnectionLocally(a); - saveConnectionLocally(b); - const loaded = loadConnectionLocally(); - expect(loaded?.id).toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); - }); - }); - - describe('loadAllConnectionsLocally', () => { - it('silently drops entries that fail schema validation (clean break)', () => { - // Write raw data including a legacy entry with old schema fields - const legacy = { - id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - type: 's3', - endpoint: 'https://old.example.com', - pathStyle: true, - region: 'eu-central-1', - accessKeyId: '', - secretAccessKey: '' - }; - const valid = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - localStorage.setItem(STORAGE_KEY, JSON.stringify([legacy, valid])); - - const stored = loadAllConnectionsLocally(); - expect(stored).toHaveLength(1); - expect(stored[0].id).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); - }); - - it('returns an empty array when localStorage is empty', () => { - expect(loadAllConnectionsLocally()).toEqual([]); - }); - }); - - describe('removeConnectionLocally', () => { - it('removes a connection matched by id', () => { - const a = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - const b = makeConn({ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }); - saveConnectionLocally(a); - saveConnectionLocally(b); - - removeConnectionLocally(a); - - const stored = loadAllConnectionsLocally(); - expect(stored).toHaveLength(1); - expect(stored[0].id).toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); - }); - }); - - describe('removeConnectionById', () => { - it('removes a connection by id string', () => { - const a = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - const b = makeConn({ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }); - saveConnectionLocally(a); - saveConnectionLocally(b); - - removeConnectionById('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); - - const stored = loadAllConnectionsLocally(); - expect(stored).toHaveLength(1); - expect(stored[0].id).toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); - }); - }); - - describe('loadConnectionById', () => { - it('returns the connection with the given id', () => { - const a = makeConn({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }); - saveConnectionLocally(a); - const found = loadConnectionById('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); - expect(found?.id).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); - }); - - it('returns null when the id does not exist', () => { - expect(loadConnectionById('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')).toBeNull(); - }); - }); -}); diff --git a/src/lib/storage/connection-storage.ts b/src/lib/storage/connection-storage.ts deleted file mode 100644 index dd7ee8b1..00000000 --- a/src/lib/storage/connection-storage.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { z } from 'zod'; -import { StorageConnectionSchema } from './schemas.js'; - -/** Raw form data — `id` may be absent when creating a new connection. */ -export type StoredConnection = z.infer; - -/** - * A connection that has been persisted to localStorage and always has an `id`. - * All load/update/remove functions work with this type. - */ -export type SavedConnection = StoredConnection & { id: string }; - -/** - * HTTP header name used to pass the S3 connection config from the browser to - * the backend API endpoints. Defined here (client-safe module) so both client - * and server code can import it without leaking server-only code to the browser. - */ -export const STORAGE_CONNECTION_HEADER = 'x-storage-connection'; - -const STORAGE_KEY = 'stackable_storage_connections'; - -/** - * Load all stored connections. Entries that do not satisfy the current schema - * (clean-break migration) are silently dropped. - * Returns connections oldest-first (most recent is last). - */ -function getConnections(): SavedConnection[] { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown[]; - return parsed.flatMap((c) => { - const result = StorageConnectionSchema.safeParse(c); - if (!result.success || !result.data.id) return []; - return [result.data as SavedConnection]; - }); - } catch { - return []; - } -} - -/** - * Save a new connection. Generates a UUID `id` if one is not already set. - * Connections are stored oldest-first; the new connection is appended (most recently used). - */ -export function saveConnectionLocally(data: StoredConnection): void { - try { - const all = getConnections(); - // If a caller-supplied id is present, look up by id first (edit / reconnect - // with a known id). Only fall back to content-matching when no id is given, - // so that reconnecting with the same credentials (host+port+accessKey) after - // a page reload does not create a duplicate entry. - const existing = data.id - ? all.find((c) => c.id === data.id) - : all.find( - (c) => - c.host === data.host && - c.port === data.port && - c.credentials?.accessKey === data.credentials?.accessKey - ); - const id = existing?.id ?? data.id ?? crypto.randomUUID(); - const connection: SavedConnection = { ...data, id }; - const filtered = all.filter((c) => c.id !== id); - filtered.push(connection); - localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); - } catch { - // localStorage may be unavailable (e.g. private browsing quota exceeded) - } -} - -/** - * Update an existing connection in-place by its `id`, preserving its position - * in the list. If no connection with that `id` exists, this is a no-op. - */ -export function updateConnectionLocally(id: string, data: Partial): void { - try { - const all = getConnections(); - const idx = all.findIndex((c) => c.id === id); - if (idx === -1) return; - all[idx] = { ...all[idx], ...data, id }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); - } catch { - // localStorage may be unavailable - } -} - -/** Return the most recently used connection (last in the list), or null. */ -export function loadConnectionLocally(): SavedConnection | null { - const all = getConnections(); - return all.length > 0 ? all[all.length - 1] : null; -} - -/** Return all stored connections in stored order (oldest first). */ -export function loadAllConnectionsLocally(): SavedConnection[] { - return getConnections(); -} - -/** Remove a specific connection from storage by its `id`. */ -export function removeConnectionLocally(data: SavedConnection): void { - try { - const all = getConnections(); - const filtered = all.filter((c) => c.id !== data.id); - localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); - } catch { - // localStorage may be unavailable - } -} - -/** Remove a connection from storage by its `id` string. */ -export function removeConnectionById(id: string): void { - try { - const all = getConnections(); - const filtered = all.filter((c) => c.id !== id); - localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); - } catch { - // localStorage may be unavailable - } -} - -/** Return a single connection by its `id`, or null if not found. */ -export function loadConnectionById(id: string): SavedConnection | null { - return getConnections().find((c) => c.id === id) ?? null; -} - -/** - * Encode a connection config as a base64 JSON string suitable for the - * `X-Storage-Connection` request header sent to the backend. - */ -export function getConnectionHeader(connection: StoredConnection): string { - return btoa(JSON.stringify(connection)); -} diff --git a/src/lib/storage/connection-store.svelte.spec.ts b/src/lib/storage/connection-store.svelte.spec.ts new file mode 100644 index 00000000..cef8f9a1 --- /dev/null +++ b/src/lib/storage/connection-store.svelte.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { + connectionHostname, + connectionStore, + type ConnectionListItem +} from './connection-store.svelte.js'; + +describe('connectionStore', () => { + it('extracts the hostname from a connection endpoint', () => { + expect(connectionHostname({ endpoint: 's3.example.com:9000' } as ConnectionListItem)).toBe( + 's3.example.com' + ); + }); + + it('extracts an IPv6 hostname from a connection endpoint', () => { + expect(connectionHostname({ endpoint: '[2001:db8::1]:9000' } as ConnectionListItem)).toBe( + '[2001:db8::1]' + ); + }); + it('starts with null activeConnectionId', () => { + expect(connectionStore.activeConnectionId).toBeNull(); + }); + + it('starts with empty connections', () => { + expect(connectionStore.connections).toEqual([]); + }); + + it('activeConnection returns null when no connection is active', () => { + expect(connectionStore.activeConnection).toBeNull(); + }); + + it('activeConnection returns null when activeConnectionId does not match any connection', () => { + connectionStore.activeConnectionId = 'non-existent'; + expect(connectionStore.activeConnection).toBeNull(); + }); + + it('activeConnection returns matching connection', () => { + const conn: ConnectionListItem = { + id: 'conn-1', + name: 'My S3', + endpoint: 's3.example.com', + additionalBuckets: [], + createdAt: '2024-01-01', + updatedAt: '2024-01-02' + }; + connectionStore.connections.push(conn); + connectionStore.activeConnectionId = 'conn-1'; + expect(connectionStore.activeConnection).toEqual(conn); + }); + + it('handles switching activeConnectionId', () => { + const conn1: ConnectionListItem = { + id: 'c1', + name: 'First', + endpoint: null, + additionalBuckets: [], + createdAt: '', + updatedAt: '' + }; + const conn2: ConnectionListItem = { + id: 'c2', + name: 'Second', + endpoint: null, + additionalBuckets: [], + createdAt: '', + updatedAt: '' + }; + connectionStore.connections.push(conn1, conn2); + connectionStore.activeConnectionId = 'c1'; + expect(connectionStore.activeConnection?.name).toBe('First'); + connectionStore.activeConnectionId = 'c2'; + expect(connectionStore.activeConnection?.name).toBe('Second'); + }); +}); diff --git a/src/lib/storage/connection-store.svelte.ts b/src/lib/storage/connection-store.svelte.ts new file mode 100644 index 00000000..b73ef61f --- /dev/null +++ b/src/lib/storage/connection-store.svelte.ts @@ -0,0 +1,49 @@ +/** + * Client-side store for the active storage connection ID and the list of + * saved connections returned by the server. + * + * This module is client-safe. It never imports server-only code. + */ + +import { SvelteURL } from 'svelte/reactivity'; + +export interface ConnectionListItem { + id: string; + name: string; + endpoint: string | null; + additionalBuckets: string[]; + createdAt: string; + updatedAt: string; +} + +export function connectionHostname(connection: ConnectionListItem | null): string { + if (!connection?.endpoint) return ''; + try { + return new SvelteURL( + connection.endpoint.includes('://') ? connection.endpoint : `//${connection.endpoint}`, + 'http://localhost' + ).hostname; + } catch { + const endpoint = connection.endpoint; + if (!endpoint.startsWith('[')) return endpoint; + const closingBracket = endpoint.indexOf(']'); + if (closingBracket <= 1) return endpoint; + const suffix = endpoint.slice(closingBracket + 1); + const isPort = + suffix.startsWith(':') && + suffix.length > 1 && + [...suffix.slice(1)].every((char) => char >= '0' && char <= '9'); + return suffix === '' || isPort ? endpoint.slice(1, closingBracket) : endpoint; + } +} + +class ConnectionStore { + activeConnectionId = $state(null); + connections = $state([]); + + get activeConnection(): ConnectionListItem | null { + return this.connections.find((c) => c.id === this.activeConnectionId) ?? null; + } +} + +export const connectionStore = new ConnectionStore(); diff --git a/src/lib/storage/connection-types.ts b/src/lib/storage/connection-types.ts new file mode 100644 index 00000000..3808dca2 --- /dev/null +++ b/src/lib/storage/connection-types.ts @@ -0,0 +1,6 @@ +/** Minimal storage connection metadata safe to expose to client components. */ +export interface ConnectionMetadata { + id: string; + name: string; + endpoint: string | null; +} diff --git a/src/lib/storage/context.ts b/src/lib/storage/context.ts index 989b4317..4d692899 100644 --- a/src/lib/storage/context.ts +++ b/src/lib/storage/context.ts @@ -1,4 +1,6 @@ import { createContext } from 'svelte'; import type { StorageState } from './state.svelte.js'; +import type { TabsState } from './tabs.svelte.js'; export const [getStorageState, setStorageState] = createContext(); +export const [getTabsState, setTabsState] = createContext(); diff --git a/src/lib/storage/details-types.ts b/src/lib/storage/details-types.ts new file mode 100644 index 00000000..70417cb3 --- /dev/null +++ b/src/lib/storage/details-types.ts @@ -0,0 +1,123 @@ +/** Full metadata for a file object. */ +export interface FileDetails { + key: string; + name: string; + size: number; + lastModified: Date; + contentType: string | undefined; + etag: string | undefined; + customMetadata: Record | undefined; + versionId: string | undefined; + storageClass: string | undefined; + isDeleteMarker: boolean; +} + +/** A node in the tree for treemap visualization. */ +export interface TreemapNode { + name: string; + size: number; + children?: TreemapNode[]; + /** Parent directory path relative to the analyzed prefix (leaf nodes only). */ + path?: string; + /** Full S3 key including the prefix (leaf nodes only). */ + fullKey?: string; + /** Set on a synthetic node that stands for this many files left out of the + * treemap; `name` is empty and the client renders a label. */ + moreCount?: number; +} + +/** Progress event from the directory-size SSE endpoint. */ +export interface DirectorySizeProgress { + type: 'progress'; + keysFound: number; + totalSize: number; +} + +/** Final result from the directory-size SSE endpoint. */ +export interface DirectorySizeResult { + type: 'complete'; + totalSize: number; + totalKeys: number; + totalFiles: number; + totalDirectories: number; + /** Pruned to the largest files; the rest is grouped into `moreCount` nodes. */ + tree: TreemapNode; + childrenByDepth: Record; + maxDepth: number; + durationMs: number; + /** Listing stopped at the configured key limit; all figures are partial. */ + truncated: boolean; +} + +/** Metadata about an S3 directory/folder (bucket ACL + optional directory marker object). */ +export interface DirectoryMetadata { + /** `null` when the bucket ACL could not be loaded. */ + bucketOwner: string | null; + bucketGrants: Array<{ grantee: string; permission: string }> | null; + markerExists: boolean; + markerLastModified?: string; + markerContentType?: string; + markerETag?: string; + markerContentLength?: number; + markerVersionId?: string; + markerStorageClass?: string; + markerServerSideEncryption?: string; + markerCustomMetadata?: Record; + markerObjectLockMode?: string; + markerObjectLockRetainUntilDate?: string; + markerObjectLockLegalHoldStatus?: string; + markerIsDeleteMarker?: boolean; +} + +/** A single child item (file or folder) in the first level of a directory listing. */ +export interface DirectoryChildItem { + name: string; + size: number; + lastModified: string | undefined; + isDirectory: boolean; +} + +/** The largest entries at one depth, plus what was left out. */ +export interface DirectoryChildList { + items: DirectoryChildItem[]; + omittedCount: number; + omittedSize: number; +} + +/** Error event from the directory-size SSE endpoint. */ +export interface DirectorySizeError { + type: 'error'; + message: string; +} + +export type DirectorySizeEvent = DirectorySizeProgress | DirectorySizeResult | DirectorySizeError; + +/** Lifecycle rule from S3 bucket lifecycle configuration. */ +export interface LifecycleRule { + id: string; + status: 'Enabled' | 'Disabled'; + filter: Record; + transitions: Array<{ days: number; storageClass: string }>; + expirations: Array<{ days?: number; date?: string; expiredObjectDeleteMarker?: boolean }>; + noncurrentVersionTransitions: Array<{ noncurrentDays: number; storageClass: string }>; + noncurrentVersionExpirations: Array<{ noncurrentDays: number }>; + abortIncompleteMultipartUploads: Array<{ daysAfterInitiation: number }>; +} + +/** Bucket ACL information. */ +export interface BucketAcl { + owner: string; + grants: Array<{ grantee: string; permission: string }>; +} + +/** Bucket versioning state; `Disabled` means versioning was never enabled. */ +export type BucketVersioning = 'Enabled' | 'Suspended' | 'Disabled'; + +/** Bucket configuration details. A `null` field could not be loaded. */ +export interface BucketDetails { + name: string; + versioning: BucketVersioning | null; + lifecycleRules: LifecycleRule[] | null; + tags: Record | null; + acl: BucketAcl | null; +} diff --git a/src/lib/storage/display-helpers.spec.ts b/src/lib/storage/display-helpers.spec.ts new file mode 100644 index 00000000..c5822145 --- /dev/null +++ b/src/lib/storage/display-helpers.spec.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + storageHref, + pinnedLabel, + pinnedHref, + fileName, + fileLocation, + fileHref, + locationName, + locationPath, + locationHref +} from './display-helpers.js'; +import type { PinnedLocation, RecentFile, RecentLocation } from './types.js'; + +vi.mock('$app/paths', () => ({ + resolve: (_route: string, params: Record) => { + const connection = params.connection ?? ''; + const bucket = params.bucket ?? ''; + const prefix = params.prefix ?? ''; + return `/storage/browse/${connection}/${bucket}${prefix ? '/' + prefix : ''}` as never; + } +})); + +describe('storageHref', () => { + it('generates href for bucket root', () => { + expect(storageHref('s3.example.com', 'my-bucket', '')).toBe( + '/storage/browse/s3.example.com/my-bucket' + ); + }); + + it('generates href with prefix', () => { + expect(storageHref('s3.example.com', 'my-bucket', 'some/path')).toBe( + '/storage/browse/s3.example.com/my-bucket/some/path' + ); + }); + + it('encodes special characters in bucket', () => { + expect(storageHref('s3.example.com', 'my bucket', '')).toBe( + '/storage/browse/s3.example.com/my%20bucket' + ); + }); + + it('encodes special characters in prefix segments', () => { + expect(storageHref('s3.example.com', 'bucket', 'path/with spaces/file.txt')).toBe( + '/storage/browse/s3.example.com/bucket/path/with%20spaces/file.txt' + ); + }); +}); + +describe('pinnedLabel', () => { + it('returns bucket name when no prefix', () => { + const pin: PinnedLocation = { connectionId: 'c1', bucket: 'my-bucket', prefix: '' }; + expect(pinnedLabel(pin)).toBe('my-bucket'); + }); + + it('returns last segment of prefix', () => { + const pin: PinnedLocation = { + connectionId: 'c1', + bucket: 'my-bucket', + prefix: 'a/b/deep-path' + }; + expect(pinnedLabel(pin)).toBe('deep-path'); + }); +}); + +describe('pinnedHref', () => { + it('generates href from pinned location', () => { + const pin: PinnedLocation = { connectionId: 'c1', bucket: 'my-bucket', prefix: 'some/path' }; + expect(pinnedHref('s3.example.com', pin)).toBe( + '/storage/browse/s3.example.com/my-bucket/some/path' + ); + }); +}); + +describe('fileName', () => { + it('extracts filename from key', () => { + expect(fileName('path/to/file.txt')).toBe('file.txt'); + }); + + it('handles root-level key', () => { + expect(fileName('file.txt')).toBe('file.txt'); + }); + + it('handles trailing slash', () => { + expect(fileName('path/to/dir/')).toBe('dir'); + }); +}); + +describe('fileLocation', () => { + it('returns bucket name for root-level file', () => { + const file: RecentFile = { + key: 'file.txt', + bucket: 'my-bucket', + size: 100, + visitedAt: '2024-01-01', + connectionId: 'c1' + }; + expect(fileLocation(file)).toBe('my-bucket'); + }); + + it('returns bucket with path segments for nested file', () => { + const file: RecentFile = { + key: 'a/b/c/file.txt', + bucket: 'my-bucket', + size: 100, + visitedAt: '2024-01-01', + connectionId: 'c1' + }; + expect(fileLocation(file)).toBe('my-bucket / a / b / c'); + }); +}); + +describe('fileHref', () => { + it('generates href to parent directory of the file', () => { + const file: RecentFile = { + key: 'a/b/file.txt', + bucket: 'my-bucket', + size: 100, + visitedAt: '2024-01-01', + connectionId: 'c1' + }; + expect(fileHref('s3.example.com', file)).toBe('/storage/browse/s3.example.com/my-bucket/a/b'); + }); + + it('generates href to bucket root for top-level file', () => { + const file: RecentFile = { + key: 'file.txt', + bucket: 'my-bucket', + size: 100, + visitedAt: '2024-01-01', + connectionId: 'c1' + }; + expect(fileHref('s3.example.com', file)).toBe('/storage/browse/s3.example.com/my-bucket'); + }); +}); + +describe('locationName', () => { + it('returns bucket name when no prefix', () => { + const loc: RecentLocation = { + bucket: 'my-bucket', + prefix: '', + visitedAt: '', + connectionId: 'c1' + }; + expect(locationName(loc)).toBe('my-bucket'); + }); + + it('returns last prefix segment', () => { + const loc: RecentLocation = { + bucket: 'my-bucket', + prefix: 'a/b/c', + visitedAt: '', + connectionId: 'c1' + }; + expect(locationName(loc)).toBe('c'); + }); +}); + +describe('locationPath', () => { + it('returns bucket name when no prefix', () => { + const loc: RecentLocation = { + bucket: 'my-bucket', + prefix: '', + visitedAt: '', + connectionId: 'c1' + }; + expect(locationPath(loc)).toBe('my-bucket'); + }); + + it('returns bucket / prefix path', () => { + const loc: RecentLocation = { + bucket: 'my-bucket', + prefix: 'a/b/c', + visitedAt: '', + connectionId: 'c1' + }; + expect(locationPath(loc)).toBe('my-bucket / a / b / c'); + }); +}); + +describe('locationHref', () => { + it('generates href from recent location', () => { + const loc: RecentLocation = { + bucket: 'my-bucket', + prefix: 'some/path', + visitedAt: '', + connectionId: 'c1' + }; + expect(locationHref('s3.example.com', loc)).toBe( + '/storage/browse/s3.example.com/my-bucket/some/path' + ); + }); +}); diff --git a/src/lib/storage/display-helpers.ts b/src/lib/storage/display-helpers.ts index d9e4874f..4e7508e6 100644 --- a/src/lib/storage/display-helpers.ts +++ b/src/lib/storage/display-helpers.ts @@ -2,16 +2,20 @@ import { resolve } from '$app/paths'; import type { ResolvedPathname } from '$app/types'; import type { PinnedLocation, RecentFile, RecentLocation } from './types.js'; -const STORAGE_ROUTE = '/(app)/storage/[bucket]/[...prefix]' as const; +const STORAGE_ROUTE = '/(app)/storage/browse/[connection]/[bucket]/[...prefix]' as const; // ── URL helpers ────────────────────────────────────────────────────────────── -export function storageHref(bucket: string, prefix: string): ResolvedPathname { +export function storageHref(connection: string, bucket: string, prefix: string): ResolvedPathname { const encodedBucket = encodeURIComponent(bucket); const encodedPrefix = prefix ? prefix.replace(/\/$/, '').split('/').map(encodeURIComponent).join('/') : ''; - return resolve(STORAGE_ROUTE, { bucket: encodedBucket, prefix: encodedPrefix }); + return resolve(STORAGE_ROUTE, { + connection: encodeURIComponent(connection), + bucket: encodedBucket, + prefix: encodedPrefix + }); } // ── Pinned location helpers ────────────────────────────────────────────────── @@ -22,8 +26,8 @@ export function pinnedLabel(pin: PinnedLocation): string { return parts[parts.length - 1] ?? pin.bucket; } -export function pinnedHref(pin: PinnedLocation): ResolvedPathname { - return storageHref(pin.bucket, pin.prefix); +export function pinnedHref(connection: string, pin: PinnedLocation): ResolvedPathname { + return storageHref(connection, pin.bucket, pin.prefix); } // ── Recent file helpers ────────────────────────────────────────────────────── @@ -39,11 +43,12 @@ export function fileLocation(file: RecentFile): string { return parts.length > 0 ? `${file.bucket} / ${parts.join(' / ')}` : file.bucket; } -export function fileHref(file: RecentFile): ResolvedPathname { +export function fileHref(connection: string, file: RecentFile): ResolvedPathname { const parts = file.key.split('/').filter(Boolean); parts.pop(); const encodedPrefix = parts.map(encodeURIComponent).join('/'); return resolve(STORAGE_ROUTE, { + connection: encodeURIComponent(connection), bucket: encodeURIComponent(file.bucket), prefix: encodedPrefix }); @@ -63,6 +68,6 @@ export function locationPath(loc: RecentLocation): string { return `${loc.bucket} / ${parts.join(' / ')}`; } -export function locationHref(loc: RecentLocation): ResolvedPathname { - return storageHref(loc.bucket, loc.prefix); +export function locationHref(connection: string, loc: RecentLocation): ResolvedPathname { + return storageHref(connection, loc.bucket, loc.prefix); } diff --git a/src/lib/storage/download-history.spec.ts b/src/lib/storage/download-history.spec.ts new file mode 100644 index 00000000..55c749e6 --- /dev/null +++ b/src/lib/storage/download-history.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { selectedDownloadHistoryPayloadSize } from './download-history.js'; + +const entries = [ + { key: 'a.txt', size: 10, isDirectory: false }, + { key: 'folder/', size: 0, isDirectory: true }, + { key: 'folder/b.txt', size: 20, isDirectory: false }, + { key: 'folder/nested/c.txt', size: 30, isDirectory: false } +]; + +describe('selectedDownloadHistoryPayloadSize', () => { + it('sums directly selected files', () => { + expect(selectedDownloadHistoryPayloadSize(entries, ['a.txt', 'folder/b.txt'])).toBe(30); + }); + + it('expands selected directories to their stored descendants', () => { + expect(selectedDownloadHistoryPayloadSize(entries, ['folder/'])).toBe(50); + }); + + it('deduplicates direct children selected alongside their directory', () => { + expect(selectedDownloadHistoryPayloadSize(entries, ['folder/', 'folder/b.txt'])).toBe(50); + }); +}); diff --git a/src/lib/storage/download-history.ts b/src/lib/storage/download-history.ts new file mode 100644 index 00000000..ebf8fcac --- /dev/null +++ b/src/lib/storage/download-history.ts @@ -0,0 +1,26 @@ +import type { DownloadHistoryEntry } from './api.js'; + +/** Return the persisted payload size for the selected history entries. */ +export function selectedDownloadHistoryPayloadSize( + entries: DownloadHistoryEntry['entries'], + selectedKeys: Iterable +): number { + const entriesByKey = new Map(entries.map((entry) => [entry.key, entry])); + const selectedEntries = new Map(); + + for (const key of selectedKeys) { + const entry = entriesByKey.get(key); + if (!entry) continue; + if (entry.isDirectory) { + for (const descendant of entries) { + if (descendant.key === entry.key || descendant.key.startsWith(entry.key)) { + selectedEntries.set(descendant.key, descendant); + } + } + } else { + selectedEntries.set(entry.key, entry); + } + } + + return [...selectedEntries.values()].reduce((total, entry) => total + entry.size, 0); +} diff --git a/src/lib/storage/download.spec.ts b/src/lib/storage/download.spec.ts new file mode 100644 index 00000000..107d418f --- /dev/null +++ b/src/lib/storage/download.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { estimateArchiveSize, startDownload } from './download.js'; +import { createMemoryStorageApi } from './api.test-utils.js'; + +describe('estimateArchiveSize', () => { + it('returns the end-of-central-directory size for no entries', () => { + expect(estimateArchiveSize([])).toBe(22); + }); + + it('adds per-entry structure overhead to the payload total', () => { + // Each entry contributes 92 bytes of fixed structure plus its name stored + // twice ('a.txt'/'b.txt' = 5 characters each), on top of the 22-byte EOCD + // and the payload itself. + const estimate = estimateArchiveSize([ + { key: 'a.txt', size: 5, isDirectory: false }, + { key: 'b.txt', size: 10, isDirectory: false } + ]); + expect(estimate).toBe(22 + 2 * (92 + 2 * 5) + 15); + }); + + it('appends a slash to directory entry names without one', () => { + const withSlash = estimateArchiveSize([{ key: 'dir/', size: 0, isDirectory: true }]); + const withoutSlash = estimateArchiveSize([{ key: 'dir', size: 0, isDirectory: true }]); + expect(withoutSlash).toBe(withSlash); + expect(withSlash).toBe(22 + 92 + 2 * 4); + }); + + it('scales with many entries so large downloads show a meaningful size', () => { + const entries = Array.from({ length: 1_000 }, (_, index) => ({ + key: `file-${index}.bin`, + size: 1_000_000, + isDirectory: false + })); + const estimate = estimateArchiveSize(entries); + expect(estimate).toBeGreaterThan(1_000 * 1_000_000); + expect(estimate).toBeLessThan(1_001 * 1_000_000); + }); +}); + +describe('startDownload', () => { + it('starts every manifest stream without polling or local persistence', async () => { + vi.useFakeTimers(); + const api = createMemoryStorageApi({ + createDownloadManifest: async () => ({ + id: 'manifest-1', + expiresAt: new Date().toISOString(), + files: [ + { filename: 'one.txt', size: 1, part: 1 }, + { filename: 'two.txt', size: 2, part: 2 } + ] + }) + }); + const click = vi.fn(); + vi.stubGlobal('document', { + createElement: () => ({ click, style: {}, remove: vi.fn() }), + body: { appendChild: vi.fn() } + }); + + const promise = startDownload(api, 'bucket', '', ['one.txt', 'two.txt']); + await vi.advanceTimersByTimeAsync(500); + + await expect(promise).resolves.toMatchObject({ + id: 'manifest-1', + fileCount: 2, + totalBytes: 3, + jobIds: expect.arrayContaining([expect.any(String)]) + }); + expect(click).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/lib/storage/download.ts b/src/lib/storage/download.ts index b5551add..f56aeefd 100644 --- a/src/lib/storage/download.ts +++ b/src/lib/storage/download.ts @@ -1,86 +1,78 @@ +import type { StorageApi } from './api.js'; + /** - * Client-side utility for downloading a single S3 object via the server proxy. - * - * Strategy: - * 1. Fetch the object with the `X-Storage-Connection` header carrying the - * connection config from localStorage. - * 2. On error: throw a `DownloadError` with a typed `code` so the caller can - * display a localised message. - * 3. On success: create a Blob URL and trigger a native browser download via a - * programmatic anchor click. - * - * Note: The response body is buffered as a Blob before the download link is - * constructed. This avoids exposing credentials in the URL (query-param approach) - * while keeping the implementation simple. For very large files this will use - * proportional browser memory. + * Estimate the byte length of the stored (uncompressed) ZIP that a multi + * download will produce while preparing the manifest. The server's exact + * manifest size replaces this temporary estimate before transfer tracking. */ - -import { STORAGE_CONNECTION_HEADER } from '$lib/storage/connection-storage.js'; - -export type DownloadErrorCode = - 'not_connected' | 'access_denied' | 'not_found' | 'server_error' | 'unknown'; - -export class DownloadError extends Error { - constructor( - public readonly code: DownloadErrorCode, - message: string - ) { - super(message); - this.name = 'DownloadError'; +export function estimateArchiveSize( + entries: Array<{ key: string; size: number; isDirectory?: boolean }> +): number { + let payload = 0; + // End-of-central-directory record. + let overhead = 22; + for (const entry of entries) { + const nameLength = entry.key.length + (entry.isDirectory && !entry.key.endsWith('/') ? 1 : 0); + // Local file header (30) + data descriptor (16) + central directory header + // (46), with the entry name stored twice (local header + central directory). + overhead += 92 + 2 * nameLength; + if (!entry.isDirectory) payload += entry.size; } + return payload + overhead; } -function buildDownloadUrl(bucket: string, key: string): string { - return `/api/storage/download?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`; -} - -function mapStatusToCode(status: number): DownloadErrorCode { - if (status === 401) return 'not_connected'; - if (status === 403) return 'access_denied'; - if (status === 404) return 'not_found'; - if (status >= 500) return 'server_error'; - return 'unknown'; -} - -/** - * Download a single S3 object. - * - * Fetches the object with the connection config header, buffers it as a Blob, - * then triggers a native browser download via a programmatic anchor click. - * - * @throws {DownloadError} when the server returns a non-2xx response. - */ -export async function downloadObject( - bucket: string, - key: string, - connectionHeader: string +async function triggerDownload( + manifestId: string, + part: number, + filename: string, + jobId: string ): Promise { - const url = buildDownloadUrl(bucket, key); - - const response = await fetch(url, { - headers: { [STORAGE_CONNECTION_HEADER]: connectionHeader } - }); - - if (!response.ok) { - const code = mapStatusToCode(response.status); - throw new DownloadError(code, `Download failed with status ${response.status}`); - } - - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); - - // Derive filename from the key (last path segment). - const filename = key.split('/').filter(Boolean).pop() ?? key; - const anchor = document.createElement('a'); - anchor.href = blobUrl; + anchor.href = `/api/storage/download/manifests/${encodeURIComponent(manifestId)}/${part}?${new URLSearchParams({ jobId })}`; anchor.download = filename; anchor.style.display = 'none'; document.body.appendChild(anchor); anchor.click(); - document.body.removeChild(anchor); + // Keep concurrent native downloads initiated by this user gesture alive long enough + // for Firefox and Chromium to accept them. + await new Promise((resolve) => setTimeout(resolve, 250)); + setTimeout(() => anchor.remove(), 10_000); +} - // Release the object URL after a short delay to allow the browser to initiate - // the download before the URL is revoked. - setTimeout(() => URL.revokeObjectURL(blobUrl), 10_000); +export async function triggerManifestDownloads( + manifest: { + id: string; + files: Array<{ filename: string; part: number }>; + }, + signal?: AbortSignal, + onJobIds?: (jobIds: string[]) => void +): Promise { + const jobIds: string[] = []; + for (const file of manifest.files) { + signal?.throwIfAborted(); + const jobId = crypto.randomUUID(); + jobIds.push(jobId); + onJobIds?.([...jobIds]); + await triggerDownload(manifest.id, file.part, file.filename, jobId); + } + return jobIds; +} + +/** Prepare a final manifest, then immediately hand its streams to the browser. */ +export async function startDownload( + api: StorageApi, + bucket: string, + prefix: string, + keys: string[], + signal?: AbortSignal, + onJobIds?: (jobIds: string[]) => void +): Promise<{ id: string; fileCount: number; totalBytes: number; jobIds: string[] }> { + const manifest = await api.createDownloadManifest({ bucket, prefix, keys }, signal); + const jobIds = await triggerManifestDownloads(manifest, signal, onJobIds); + return { + id: manifest.id, + fileCount: manifest.files.length, + totalBytes: manifest.files.reduce((total, file) => total + file.size, 0), + jobIds + }; } diff --git a/src/lib/storage/drag-handlers.ts b/src/lib/storage/drag-handlers.ts new file mode 100644 index 00000000..5c608996 --- /dev/null +++ b/src/lib/storage/drag-handlers.ts @@ -0,0 +1,59 @@ +import { SvelteSet } from 'svelte/reactivity'; +import { storageCutCopyEnabled, storageMoveEnabled } from '$lib/client/feature-flags.js'; +import { connectionStore } from './connection-store.svelte.js'; +import type { DragSource } from './clipboard.svelte.js'; + +const KEYS_TYPE = 'application/x-storage-keys'; +/** Where the dragged items come from: connection and bucket at drag start. */ +const SOURCE_TYPE = 'application/x-storage-source'; + +/** + * Auto-select the dragged item if not already selected, then attach + * the selected keys to the drag event for the drop target. + * + * NOTE: `state` must be the reactive storage object so that assignment + * to `selectedKeys` triggers reactivity. + */ +export function handleRowDragStart( + e: DragEvent, + itemKey: string, + state: { selectedKeys: Set; bucket: string; archive: { isInArchive: boolean } } +): void { + if (!storageCutCopyEnabled || state.archive.isInArchive) return; + if (!state.selectedKeys.has(itemKey)) { + state.selectedKeys = new SvelteSet([itemKey]); + } + e.dataTransfer?.setData(KEYS_TYPE, JSON.stringify([...state.selectedKeys])); + const source: DragSource = { + connectionId: connectionStore.activeConnectionId, + bucket: state.bucket + }; + e.dataTransfer?.setData(SOURCE_TYPE, JSON.stringify(source)); + e.dataTransfer!.effectAllowed = 'move'; +} + +/** + * Parse the dragged keys and where they come from. Returns null if either is + * missing or invalid, e.g. a drag from an older page or another application. + */ +export function parseStorageDrop(e: DragEvent): { keys: string[]; source: DragSource } | null { + const rawKeys = e.dataTransfer?.getData(KEYS_TYPE); + const rawSource = e.dataTransfer?.getData(SOURCE_TYPE); + if (!rawKeys || !rawSource) return null; + try { + const keys = JSON.parse(rawKeys) as unknown; + const source = JSON.parse(rawSource) as Partial; + if (!Array.isArray(keys) || !keys.every((key) => typeof key === 'string')) return null; + if (typeof source.bucket !== 'string') return null; + return { keys, source: { connectionId: source.connectionId ?? null, bucket: source.bucket } }; + } catch { + return null; + } +} + +/** + * Check whether a storage drag-drop is allowed in the current context. + */ +export function canStorageDrop(storage: { archive: { isInArchive: boolean } }): boolean { + return storageMoveEnabled && !storage.archive.isInArchive; +} diff --git a/src/lib/storage/errors.spec.ts b/src/lib/storage/errors.spec.ts new file mode 100644 index 00000000..f7b83c3e --- /dev/null +++ b/src/lib/storage/errors.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest'; +import { StorageError, getActionErrorMessage, getActionErrorMessageForCode } from './errors.js'; + +const mockMessages = vi.hoisted(() => ({ + storage_download_error_not_connected: () => 'No storage connection configured', + storage_download_error_access_denied: () => 'Access denied', + storage_download_error_not_found: () => 'File not found', + storage_download_error_server_error: () => 'Server error', + storage_upload_error_no_such_bucket: () => 'Bucket not found', + storage_upload_error_invalid_part: () => 'Invalid part', + storage_download_error_unknown: () => 'Unknown error' +})); + +vi.mock('$lib/paraglide/messages.js', () => mockMessages); + +describe('StorageError', () => { + it('creates an error with code and message', () => { + const err = new StorageError('not_found', 'File was not found'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('StorageError'); + expect(err.code).toBe('not_found'); + expect(err.message).toBe('File was not found'); + }); +}); + +describe('getActionErrorMessage', () => { + it('maps not_connected code', () => { + expect(getActionErrorMessage(new StorageError('not_connected', ''))).toBe( + 'No storage connection configured' + ); + }); + + it('maps access_denied code', () => { + expect(getActionErrorMessage(new StorageError('access_denied', ''))).toBe('Access denied'); + }); + + it('maps not_found code', () => { + expect(getActionErrorMessage(new StorageError('not_found', ''))).toBe('File not found'); + }); + + it('maps server_error code', () => { + expect(getActionErrorMessage(new StorageError('server_error', ''))).toBe('Server error'); + }); + + it('maps no_such_bucket code', () => { + expect(getActionErrorMessage(new StorageError('no_such_bucket', ''))).toBe('Bucket not found'); + }); + + it('maps invalid_part code', () => { + expect(getActionErrorMessage(new StorageError('invalid_part', ''))).toBe('Invalid part'); + }); + + it('maps unknown code to unknown error message', () => { + expect(getActionErrorMessage(new StorageError('unknown', ''))).toBe('Unknown error'); + }); + + it('maps any unrecognised code to unknown error message', () => { + expect(getActionErrorMessage(new StorageError('some_weird_code', ''))).toBe('Unknown error'); + }); +}); + +describe('getActionErrorMessageForCode', () => { + it('forwards to getActionErrorMessage with a StorageError', () => { + expect(getActionErrorMessageForCode('not_found')).toBe('File not found'); + expect(getActionErrorMessageForCode('unknown')).toBe('Unknown error'); + }); +}); diff --git a/src/lib/storage/errors.ts b/src/lib/storage/errors.ts index 58f32852..a8676bed 100644 --- a/src/lib/storage/errors.ts +++ b/src/lib/storage/errors.ts @@ -1,20 +1,32 @@ import * as m from '$lib/paraglide/messages.js'; -// ── ActionError ────────────────────────────────────────────────────────────── +// ── StorageError (unified error hierarchy) ─────────────────────────────────── -export class ActionError extends Error { +export type StorageErrorCode = + | 'not_connected' + | 'access_denied' + | 'not_found' + | 'conflict' + | 'server_error' + | 'no_such_bucket' + | 'invalid_part' + | 'file_too_large' + | 'too_many_results' + | 'unknown'; + +export class StorageError extends Error { constructor( public readonly code: string, message: string ) { super(message); - this.name = 'ActionError'; + this.name = 'StorageError'; } } // ── Error message mapping ──────────────────────────────────────────────────── -export function getActionErrorMessage(err: ActionError): string { +export function getActionErrorMessage(err: StorageError): string { switch (err.code) { case 'not_connected': return m.storage_download_error_not_connected(); @@ -32,3 +44,7 @@ export function getActionErrorMessage(err: ActionError): string { return m.storage_download_error_unknown(); } } + +export function getActionErrorMessageForCode(code: string): string { + return getActionErrorMessage(new StorageError(code, '')); +} diff --git a/src/lib/storage/keyboard-scope.svelte.spec.ts b/src/lib/storage/keyboard-scope.svelte.spec.ts new file mode 100644 index 00000000..fde7af27 --- /dev/null +++ b/src/lib/storage/keyboard-scope.svelte.spec.ts @@ -0,0 +1,188 @@ +vi.mock('$lib/client/feature-flags.js', () => ({ + storageRestoreTabsEnabled: false, + allowedPageSizes: [25, 50, 100], + defaultPageSize: 25, + maxRecentFiles: 15, + maxEditableFileSize: 5 * 1024 * 1024, + uploadConcurrency: 3, + storageCutCopyEnabled: true, + storagePasteEnabled: true, + storageRenameEnabled: true, + storageMoveEnabled: true +})); + +vi.mock('$lib/storage/connection-store.svelte.js', () => ({ + connectionStore: { activeConnectionId: 'test-connection-id' } +})); + +vi.mock('$lib/stores/toast.svelte.js', () => ({ + addToast: vi.fn() +})); + +vi.mock('$app/navigation', () => ({ + invalidateAll: vi.fn() +})); + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SvelteSet } from 'svelte/reactivity'; +import { StorageState } from './state.svelte.js'; +import { createMemoryStorageApi } from './api.test-utils.js'; +import { makePage } from './state.test-utils.js'; +import { + STORAGE_SHORTCUT_SCOPE_ATTR, + isStorageShortcutTarget, + isTextEntryElement +} from './keyboard-scope.js'; + +let mounted: HTMLElement[] = []; + +function mount(el: T, parent: HTMLElement = document.body): T { + parent.appendChild(el); + if (parent === document.body) mounted.push(el); + return el; +} + +function scope(): HTMLElement { + const div = document.createElement('div'); + div.setAttribute(STORAGE_SHORTCUT_SCOPE_ATTR, ''); + return mount(div); +} + +function input(type: string, parent?: HTMLElement): HTMLInputElement { + const el = document.createElement('input'); + el.type = type; + return mount(el, parent); +} + +afterEach(() => { + mounted.forEach((el) => el.remove()); + mounted = []; +}); + +describe('isTextEntryElement', () => { + it.each(['text', 'search', 'email', 'password', 'number', 'url'])( + 'treats
    - + @@ -101,11 +93,7 @@

    {m.storage_connections_title()}

    {m.storage_connections_subtitle()}

    - {#if !loaded} -
    - -
    - {:else if connections.length === 0} + {#if connections.length === 0}

    {m.storage_connections_empty()}

    {:else}
    @@ -122,7 +110,6 @@ {#each connections as conn (conn.id)} openContextMenu(e, conn)} > @@ -133,18 +120,23 @@
    - {conn.port ? `${conn.host}:${conn.port}` : conn.host} + {conn.endpoint ?? '-'} - {conn.type} + s3 - + +
    {/each} @@ -154,8 +146,11 @@ {/if}
    - + {#if menuConn} +
    + +
    -{/if} - + +{/if} diff --git a/src/routes/(app)/storage/connections/[id]/+layout.svelte b/src/routes/(app)/settings/connections/[id]/+layout.svelte similarity index 54% rename from src/routes/(app)/storage/connections/[id]/+layout.svelte rename to src/routes/(app)/settings/connections/[id]/+layout.svelte index 5610b09b..6ff76e73 100644 --- a/src/routes/(app)/storage/connections/[id]/+layout.svelte +++ b/src/routes/(app)/settings/connections/[id]/+layout.svelte @@ -3,19 +3,24 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import StorageConnectionSidebar from '$lib/components/storage/sidebar/StorageConnectionSidebar.svelte'; - import type { SavedConnection } from '$lib/storage/connection-storage.js'; + import type { ConnectionMetadata } from '$lib/storage/connection-types.js'; + import { connectionStore } from '$lib/storage/connection-store.svelte.js'; let { children } = $props(); const connectionId = $derived(page.params.id); - function handleSelect(conn: SavedConnection) { - goto(resolve(`/storage/connections/${conn.id}/edit`)); + function handleSelect(conn: ConnectionMetadata) { + goto(resolve(`/settings/connections/${conn.id}/edit`)); }
    - +
    {@render children()}
    diff --git a/src/routes/(app)/settings/connections/[id]/edit/+page.server.ts b/src/routes/(app)/settings/connections/[id]/edit/+page.server.ts new file mode 100644 index 00000000..ab79b71a --- /dev/null +++ b/src/routes/(app)/settings/connections/[id]/edit/+page.server.ts @@ -0,0 +1,233 @@ +import { error, fail, redirect, isHttpError } from '@sveltejs/kit'; +import { superValidate, message, setError } from 'sveltekit-superforms'; +import type { Infer, InferIn } from 'sveltekit-superforms'; +import { zod4 as zod } from 'sveltekit-superforms/adapters'; +import { eq, and } from 'drizzle-orm'; +import type { Actions, PageServerLoad } from './$types'; +import { + EditStorageConnectionSchema, + StoredStorageConnectionSchema, + type StorageConnectionMessage +} from '$lib/storage/schemas.js'; +import type { z } from 'zod'; +import { getConnectionProvider } from '$lib/server/storage/utils.js'; +import type { S3ConnectionConfig } from '$lib/server/storage/types.js'; +import { db } from '$lib/server/db.js'; +import { userStorageConnections } from '$lib/server/schema.js'; +import { decrypt, encrypt, fingerprint } from '$lib/server/storage/encryption.js'; +import { isConnectionNameConflict } from '$lib/server/storage/connections-db.js'; +import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js'; +import { logger } from '$lib/server/logging'; +import * as m from '$lib/paraglide/messages.js'; + +type EditData = Infer; +type EditInput = InferIn; + +const log = logger.child({ module: 'connection-edit' }); + +export const load: PageServerLoad = async ({ locals, params }) => { + const userId = locals.user!.id; + + const rows = await db + .select() + .from(userStorageConnections) + .where(and(eq(userStorageConnections.id, params.id), eq(userStorageConnections.userId, userId))) + .limit(1); + + if (rows.length === 0) { + locals.logger.debug({ connection_id: params.id }, 'connection not found, redirecting'); + throw redirect(303, '/settings/connections'); + } + + const row = rows[0]; + let payload: z.infer; + + try { + payload = StoredStorageConnectionSchema.parse( + JSON.parse(decrypt(row.encryptedPayload, storageEncryptionKey())) + ); + } catch { + locals.logger.error({ connection_id: params.id }, 'failed to decrypt connection payload'); + throw error(500, 'Failed to load connection'); + } + + const editForm = await superValidate( + { + id: params.id, + name: row.name ?? '', + type: 's3', + host: payload.host, + port: payload.port, + tls: payload.tls, + accessStyle: payload.accessStyle, + region: payload.region, + credentials: { + accessKey: payload.credentials?.accessKey ?? '', + secretKey: '' + } + }, + zod(EditStorageConnectionSchema), + { errors: false } + ); + + locals.logger.debug({ connection_id: params.id }, 'loading storage connection edit page'); + return { + editForm, + connectionId: params.id, + activeConnectionId: locals.session?.activeStorageConnectionId ?? null + }; +}; + +export const actions: Actions = { + update: async ({ request, locals, params }) => { + const form = await superValidate( + request, + zod(EditStorageConnectionSchema) + ); + + if (!form.valid) { + log.debug({ errors: form.errors }, 'storage connection edit form validation failed'); + return fail(400, { form }); + } + + const { type, host, port, tls, accessStyle, region, credentials } = form.data; + const connectionId = params.id; + const userId = locals.user!.id; + + if (type !== 's3') { + return message( + form, + { type: 'error', message: m.storage_connect_error_hdfs() }, + { status: 400 } + ); + } + + const resolvedCredentials = + credentials.accessKey && credentials.secretKey ? credentials : undefined; + + const config: S3ConnectionConfig = { + type: 's3', + host, + port, + tls, + accessStyle, + region, + credentials: resolvedCredentials + }; + + if (resolvedCredentials) { + try { + await getConnectionProvider(config).listContainers(); + log.info({ storage_type: type }, 'storage connection edit verified'); + } catch (err) { + log.warn({ err }, 'storage connection edit test failed'); + + let msg: string; + if (isHttpError(err)) { + if (err.status === 403) { + msg = m.storage_connect_error_access_denied(); + } else if (err.status === 404) { + msg = m.storage_connect_error_not_found(); + } else if (err.status === 502) { + msg = m.storage_connect_error_server_error(); + } else { + msg = m.storage_connect_error(); + } + } else if (err instanceof Error) { + const e = (err.message ?? '').toLowerCase(); + if (/econnrefused|enotfound|eai_again|etimedout|network/.test(e)) { + msg = m.storage_connect_error_network(); + } else { + msg = m.storage_connect_error(); + } + } else { + msg = m.storage_connect_error(); + } + + return message(form, { type: 'error', message: msg }, { status: 400 }); + } + } + + // If no new credentials were provided, preserve the existing encrypted ones. + let payload: z.infer; + if (resolvedCredentials) { + payload = { host, port, tls, accessStyle, region, credentials: resolvedCredentials }; + } else { + try { + const rows = await db + .select({ encryptedPayload: userStorageConnections.encryptedPayload }) + .from(userStorageConnections) + .where( + and( + eq(userStorageConnections.id, connectionId), + eq(userStorageConnections.userId, userId) + ) + ) + .limit(1); + if (rows.length === 0) { + return message( + form, + { type: 'error', message: m.storage_connect_error_not_found() }, + { status: 404 } + ); + } + const existing = StoredStorageConnectionSchema.parse( + JSON.parse(decrypt(rows[0].encryptedPayload, storageEncryptionKey())) + ); + if (credentials.accessKey && credentials.accessKey !== existing.credentials?.accessKey) { + log.debug({ connection_id: connectionId }, 'access key changed without a secret key'); + return setError( + form, + 'credentials.secretKey', + m.storage_connection_edit_secret_required() + ); + } + payload = { host, port, tls, accessStyle, region, credentials: existing.credentials }; + } catch { + return message( + form, + { type: 'error', message: m.storage_connect_error() }, + { status: 500 } + ); + } + } + + const encryptedPayload = encrypt(JSON.stringify(payload), storageEncryptionKey()); + const hash = fingerprint( + { + endpoint: host, + port, + tls, + accessStyle, + region: region.name, + accessKeyId: payload.credentials?.accessKey ?? '', + secretAccessKey: payload.credentials?.secretKey ?? '' + }, + storageEncryptionKey() + ); + + // A blank name keeps the current one; the name column is required. + const name = form.data.name?.trim() || undefined; + try { + await db + .update(userStorageConnections) + .set({ encryptedPayload, hash, name }) + .where( + and( + eq(userStorageConnections.id, connectionId), + eq(userStorageConnections.userId, userId) + ) + ); + } catch (err) { + if (name !== undefined && isConnectionNameConflict(err)) { + log.debug({ connection_id: connectionId }, 'storage connection name already in use'); + return setError(form, 'name', m.storage_connection_edit_name_taken()); + } + throw err; + } + + log.info({ connection_id: connectionId }, 'storage connection updated'); + + return message(form, { type: 'success' }); + } +}; diff --git a/src/routes/(app)/storage/connections/[id]/edit/+page.svelte b/src/routes/(app)/settings/connections/[id]/edit/+page.svelte similarity index 71% rename from src/routes/(app)/storage/connections/[id]/edit/+page.svelte rename to src/routes/(app)/settings/connections/[id]/edit/+page.svelte index 6da1d31b..9340d821 100644 --- a/src/routes/(app)/storage/connections/[id]/edit/+page.svelte +++ b/src/routes/(app)/settings/connections/[id]/edit/+page.svelte @@ -2,75 +2,38 @@ import { onMount } from 'svelte'; import { goto, beforeNavigate } from '$app/navigation'; import { resolve } from '$app/paths'; - import { page } from '$app/state'; + import type { Pathname } from '$app/types'; import { superForm } from 'sveltekit-superforms'; - import { zod4 as zod } from 'sveltekit-superforms/adapters'; + import { zod4Client as zod } from 'sveltekit-superforms/adapters'; import { untrack } from 'svelte'; import IconInfo from 'virtual:icons/material-symbols/info'; import * as m from '$lib/paraglide/messages.js'; import Modal from '$lib/components/Modal.svelte'; import { EditStorageConnectionSchema } from '$lib/storage/schemas.js'; - import { - loadConnectionById, - loadConnectionLocally, - updateConnectionLocally, - type SavedConnection - } from '$lib/storage/connection-storage.js'; let { data } = $props(); const uid = $props.id(); - // Loaded client-side from localStorage - let connection = $state(null); let loaded = $state(false); - let isActiveConnection = $state(false); + let isActiveConnection = $derived(data.activeConnectionId === data.connectionId); // Unsaved-changes guard let initialSnapshot = $state.raw(''); let confirmLeaveOpen = $state(false); let pendingNavigation = $state(null); - // Set to true just before a programmatic goto so beforeNavigate doesn't re-intercept it. let bypassDirtyCheck = false; - // Extract the id from the URL via SvelteKit's page state - const connectionId = page.params.id; - const { form, errors, enhance, submitting, message } = superForm( untrack(() => data.editForm), { dataType: 'json', validators: zod(EditStorageConnectionSchema), - onSubmit: ({ cancel }) => { - // A blank secret key means "keep the existing credentials". That only works if the - // access key is unchanged, otherwise the new access key would be silently dropped. - const { accessKey, secretKey } = $form.credentials; - const storedAccessKey = connection?.credentials?.accessKey ?? ''; - if (accessKey && !secretKey && accessKey !== storedAccessKey) { - $errors.credentials = { - ...$errors.credentials, - secretKey: [m.storage_connection_edit_secret_key_required()] - }; - cancel(); - } - }, onResult: ({ result, cancel }) => { - // Errors are returned with a 4xx status, so only a successful save yields 'success'. - if (result.type === 'success') { - if (connection) { - // If credentials were left blank, preserve the existing stored credentials. - const savedCredentials = - $form.credentials.accessKey && $form.credentials.secretKey - ? $form.credentials - : (connection.credentials ?? { accessKey: '', secretKey: '' }); - updateConnectionLocally(connection.id, { ...$form, credentials: savedCredentials }); - } - // Prevent superforms from resetting the form and calling invalidateAll(), - // which would race with the navigation. + if (result.type === 'success' && result.data?.form?.message?.type === 'success') { cancel(); - // Bypass the dirty-check guard so the post-save redirect isn't intercepted. bypassDirtyCheck = true; - goto(resolve('/storage/connections')); + goto(resolve('/settings/connections')); } } } @@ -81,34 +44,19 @@ } onMount(() => { - if (!connectionId) { - goto(resolve('/storage/connections')); - return; - } - const found = loadConnectionById(connectionId); - if (!found) { - goto(resolve('/storage/connections')); - return; - } - connection = found; - - // Pre-fill form with current values - $form.id = found.id; - $form.name = found.name ?? ''; - $form.type = found.type; - $form.host = found.host; - $form.port = found.port; - $form.tls = found.tls; - $form.accessStyle = found.accessStyle; - $form.region = found.region; - // Pre-fill access key but never expose the stored secret key. - $form.credentials = { accessKey: found.credentials?.accessKey ?? '', secretKey: '' }; - - // Check if this is the currently active connection - const active = loadConnectionLocally(); - isActiveConnection = active?.id === found.id; + // Pre-fill form explicitly (mirroring the server data) to ensure the $state + // proxy is fully settled before snapshotting for dirty detection. + const d = data.editForm.data; + $form.id = d.id; + $form.name = d.name ?? ''; + $form.type = d.type; + $form.host = d.host; + $form.port = d.port; + $form.tls = d.tls; + $form.accessStyle = d.accessStyle; + $form.region = d.region; + $form.credentials = { accessKey: d.credentials?.accessKey ?? '', secretKey: '' }; - // Snapshot the pre-filled form so we can detect unsaved changes. initialSnapshot = JSON.stringify($form); loaded = true; }); @@ -126,7 +74,8 @@ confirmLeaveOpen = false; pendingNavigation = null; bypassDirtyCheck = true; - goto(dest); + // The URL comes from SvelteKit's beforeNavigate callback. + void goto((resolve as (pathname: Pathname) => string)(dest as Pathname)); } function cancelLeave() { @@ -197,14 +146,16 @@ id="{uid}-name" name="name" type="text" - class="input w-full" + class={['input w-full', $errors?.name && 'input-error']} placeholder={m.storage_connection_edit_name_placeholder()} bind:value={$form.name} /> + {#if $errors?.name} +

    {$errors.name}

    + {/if}

    {m.storage_connection_edit_name_hint()}

    - @@ -266,7 +217,6 @@ />
    - {#if $form.tls}
    @@ -365,16 +315,17 @@

    {$message.message}

    {/if} -
    + + + {/if} diff --git a/src/routes/(app)/storage/+error.svelte b/src/routes/(app)/storage/+error.svelte index bcc2ce6a..800a73b3 100644 --- a/src/routes/(app)/storage/+error.svelte +++ b/src/routes/(app)/storage/+error.svelte @@ -2,17 +2,35 @@ import { page } from '$app/state'; import { resolve } from '$app/paths'; import IconWarning from 'virtual:icons/material-symbols/warning'; + import IconStorage from 'virtual:icons/material-symbols/storage'; + import IconChevronRight from 'virtual:icons/material-symbols/chevron-right'; import * as m from '$lib/paraglide/messages.js'; - // page.params.bucket may be absent when a client-side universal load throws an - // error (the error boundary sits at the parent /storage level, and SvelteKit - // may not populate child-route params on the page store in that case). - // Fall back to parsing the bucket segment directly from the URL path. + function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } + } + + // Path segments of /storage/browse/[connection]/[bucket]/[...prefix]. + const segments = $derived(page.url?.pathname?.split('/').filter(Boolean) ?? []); + const isBrowseRoute = $derived(segments[0] === 'storage' && segments[1] === 'browse'); + + // page.params may lack the child-route params when a client-side universal + // load throws (the error boundary sits at the parent /storage level, and + // SvelteKit does not always populate them in that case). Fall back to + // parsing the segments directly from the URL path. + const connection = $derived( + page.params.connection || (isBrowseRoute ? safeDecode(segments[2] ?? '') : '') + ); const bucket = $derived( - page.params.bucket || - decodeURIComponent(page.url?.pathname?.split('/').filter(Boolean)[1] ?? '') + page.params.bucket || (isBrowseRoute ? safeDecode(segments[3] ?? '') : '') ); + const prefixParts = $derived(isBrowseRoute ? safeDecode(segments.slice(4).join('/')) : ''); + function bucketErrorMessage(status: number, name: string): string | null { if (!name) return null; if (status === 403) return m.storage_error_access_denied({ bucket: name }); @@ -21,46 +39,79 @@ return null; } - const errorMessage = $derived(bucketErrorMessage(page.status, bucket)); + const errorMessage = $derived( + page.error?.reason === 'not-connected' + ? m.storage_error_not_connected({ connection: page.error.connection ?? connection }) + : bucketErrorMessage(page.status, bucket) + ); -
    +
    +
    + {#if connection && bucket} + + {/if} +
    + - -
    - -

    - {page.status} -

    -

    - {errorMessage ?? page.error?.message ?? ''} -

    -
    +
    + +
    +

    + {m.storage_error_title()} +

    +

    + {page.status} +

    +

    + {errorMessage ?? page.error?.message ?? ''} +

    +
    -
    - - {m.storage_error_back_to_storage()} - - +
    + + {m.storage_error_back_to_storage()} + + +
    diff --git a/src/routes/(app)/storage/+layout.server.ts b/src/routes/(app)/storage/+layout.server.ts index 17175c59..7a906f8f 100644 --- a/src/routes/(app)/storage/+layout.server.ts +++ b/src/routes/(app)/storage/+layout.server.ts @@ -1,6 +1,13 @@ import type { LayoutServerLoad } from './$types'; import { error } from '@sveltejs/kit'; +import { desc, eq } from 'drizzle-orm'; import { storageBrowserEnabled } from '$lib/server/feature-flags.js'; +import { db } from '$lib/server/db.js'; +import { userStorageConnections } from '$lib/server/schema.js'; +import { decrypt } from '$lib/server/storage/encryption.js'; +import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js'; +import type { ConnectionListItem } from '$lib/storage/connection-store.svelte.js'; +import { StoredStorageConnectionSchema } from '$lib/storage/schemas.js'; export const load: LayoutServerLoad = async ({ locals }) => { if (!storageBrowserEnabled) { @@ -9,7 +16,43 @@ export const load: LayoutServerLoad = async ({ locals }) => { locals.logger.debug('loading storage layout'); - // Connected state and bucket list are determined client-side from localStorage - // and populated by the universal +layout.ts load after hydration. - return { connected: false, buckets: [] as string[], connectionType: null }; + const userId = locals.user?.id; + if (!userId) { + return { connected: false, buckets: [] as string[], connectionType: null, connections: [] }; + } + + const rows = await db + .select() + .from(userStorageConnections) + .where(eq(userStorageConnections.userId, userId)) + .orderBy(desc(userStorageConnections.updatedAt)); + + const connections: ConnectionListItem[] = rows.map((row) => { + let endpoint: string | null = null; + try { + const payload = StoredStorageConnectionSchema.parse( + JSON.parse(decrypt(row.encryptedPayload, storageEncryptionKey())) + ); + endpoint = + payload.host && payload.port ? `${payload.host}:${payload.port}` : (payload.host ?? null); + } catch { + // Return entry without endpoint if decryption fails. + } + return { + id: row.id, + name: row.name, + endpoint, + additionalBuckets: (row.additionalBuckets as string[]) ?? [], + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString() + }; + }); + + return { + connected: false, + buckets: [] as string[], + connectionType: null, + connections, + activeConnectionId: locals.session?.activeStorageConnectionId ?? null + }; }; diff --git a/src/routes/(app)/storage/+layout.svelte b/src/routes/(app)/storage/+layout.svelte index 47be34ba..ef0849c3 100644 --- a/src/routes/(app)/storage/+layout.svelte +++ b/src/routes/(app)/storage/+layout.svelte @@ -1,33 +1,78 @@ -{#if data.connected && !isConnectionsRoute} +{#if !isConnectionsRoute && (data.connected || (data.hydrating && data.hasActiveConnection))}
    @@ -37,3 +82,6 @@ {:else} {@render children()} {/if} + + + diff --git a/src/routes/(app)/storage/+layout.ts b/src/routes/(app)/storage/+layout.ts index 2f159430..c82d37b4 100644 --- a/src/routes/(app)/storage/+layout.ts +++ b/src/routes/(app)/storage/+layout.ts @@ -1,5 +1,6 @@ import { browser } from '$app/environment'; -import { loadConnectionLocally, getConnectionHeader } from '$lib/storage/connection-storage.js'; +import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; +import { connectionStore } from '$lib/storage/connection-store.svelte.js'; import type { LayoutLoad } from './$types'; const DISCONNECTED = { @@ -9,36 +10,82 @@ const DISCONNECTED = { }; /** - * Client-side load: reads the connection from localStorage and fetches the bucket - * list from the server. During SSR this returns the disconnected default — the - * layout re-runs on the client after hydration to populate the real state. + * Universal load: on the client, auto-connects to the session's active storage + * connection. The session's `activeStorageConnectionId` (set by the connect/use + * actions and cleared by disconnect/deleteConnection) is the source of truth — + * no URL-param tricks needed. + * + * During SSR returns the disconnected default; the layout re-runs after hydration. */ -export const load: LayoutLoad = async ({ fetch, url }) => { - if (!browser) return DISCONNECTED; +export const load: LayoutLoad = async ({ fetch, data }) => { + if (!browser) { + return { + ...DISCONNECTED, + connections: data.connections ?? [], + hydrating: true as const, + hasActiveConnection: data.activeConnectionId != null + }; + } - // When the user has just explicitly disconnected, skip the auto-reconnect. - if (url.searchParams.has('disconnected')) return DISCONNECTED; + const connections = data.connections ?? []; + connectionStore.connections = connections; + + // Session's active connection ID is the authoritative signal. null means the + // user explicitly disconnected (or never connected) — show the connect form. + const targetId = data.activeConnectionId ?? null; + if (!targetId) { + connectionStore.activeConnectionId = null; + return { + ...DISCONNECTED, + connections, + hydrating: false as const, + hasActiveConnection: false as const + }; + } - const connection = loadConnectionLocally(); - if (!connection) return DISCONNECTED; + const target = connections.find((c) => c.id === targetId); + if (!target) { + // Session refers to a connection that has since been deleted. + connectionStore.activeConnectionId = null; + return { + ...DISCONNECTED, + connections, + hydrating: false as const, + hasActiveConnection: false as const + }; + } - const header = getConnectionHeader(connection); + connectionStore.activeConnectionId = target.id; try { const res = await fetch('/api/storage/buckets', { - headers: { 'x-storage-connection': header } + headers: { [STORAGE_CONNECTION_ID_HEADER]: target.id } }); - if (!res.ok) return DISCONNECTED; + if (!res.ok) { + return { + ...DISCONNECTED, + connections, + hydrating: false as const, + hasActiveConnection: false as const + }; + } const buckets = (await res.json()) as string[]; return { connected: true, buckets, - connectionType: connection.type, - connectionId: connection.id + connectionType: 's3', + connections, + hydrating: false as const, + hasActiveConnection: true as const }; } catch { - return DISCONNECTED; + return { + ...DISCONNECTED, + connections, + hydrating: false as const, + hasActiveConnection: false as const + }; } }; diff --git a/src/routes/(app)/storage/+page.server.ts b/src/routes/(app)/storage/+page.server.ts index 6dcbceea..73d2e4eb 100644 --- a/src/routes/(app)/storage/+page.server.ts +++ b/src/routes/(app)/storage/+page.server.ts @@ -1,24 +1,68 @@ -import { fail, redirect } from '@sveltejs/kit'; +import { fail, redirect, isHttpError } from '@sveltejs/kit'; import { superValidate, message } from 'sveltekit-superforms'; import { zod4 as zod } from 'sveltekit-superforms/adapters'; +import { desc, eq } from 'drizzle-orm'; import type { Actions, PageServerLoad } from './$types'; import type { Infer, InferIn } from 'sveltekit-superforms'; -import { StorageConnectionSchema, type StorageConnectionMessage } from '$lib/storage/schemas.js'; -import { listBuckets } from '$lib/server/storage/service.js'; +import { + StorageConnectionSchema, + ConnectionIdSchema, + StoredStorageConnectionSchema, + type StorageConnectionMessage +} from '$lib/storage/schemas.js'; +import { getConnectionProvider } from '$lib/server/storage/utils.js'; +import { + saveConnection, + getConnectionForUser, + deleteConnection +} from '$lib/server/storage/connections-db.js'; +import { auth } from '$lib/server/auth.js'; +import { db } from '$lib/server/db.js'; +import { userStorageConnections } from '$lib/server/schema.js'; +import { decrypt } from '$lib/server/storage/encryption.js'; +import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js'; import type { S3ConnectionConfig } from '$lib/server/storage/types.js'; +import type { ConnectionMetadata } from '$lib/storage/connection-types.js'; import * as m from '$lib/paraglide/messages.js'; type ConnectionData = Infer; type ConnectionInput = InferIn; export const load: PageServerLoad = async ({ locals }) => { + const log = locals.logger; const connectionForm = await superValidate< ConnectionData, StorageConnectionMessage, ConnectionInput >({ tls: { verification: 'Full' } }, zod(StorageConnectionSchema), { errors: false }); - locals.logger.debug('loading storage page'); - return { connectionForm }; + log.debug('loading storage page'); + + const userId = locals.user?.id; + let connections: ConnectionMetadata[] = []; + + if (userId) { + const rows = await db + .select() + .from(userStorageConnections) + .where(eq(userStorageConnections.userId, userId)) + .orderBy(desc(userStorageConnections.updatedAt)); + + connections = rows.map((row) => { + let endpoint: string | null = null; + try { + const payload = StoredStorageConnectionSchema.parse( + JSON.parse(decrypt(row.encryptedPayload, storageEncryptionKey())) + ); + endpoint = + payload.host && payload.port ? `${payload.host}:${payload.port}` : (payload.host ?? null); + } catch { + // Return entry without endpoint if decryption fails. + } + return { id: row.id, name: row.name, endpoint }; + }); + } + + return { connectionForm, connections }; }; export const actions: Actions = { @@ -58,18 +102,108 @@ export const actions: Actions = { }; try { - await listBuckets(config); + await getConnectionProvider(config).listContainers(); log.info({ storage_type: type }, 'user storage connection verified'); } catch (err) { log.warn({ err }, 'storage connection test failed'); - return message(form, { type: 'error', message: m.storage_connect_error() }, { status: 400 }); + + let msg: string; + if (isHttpError(err)) { + if (err.status === 403) { + msg = m.storage_connect_error_access_denied(); + } else if (err.status === 404) { + msg = m.storage_connect_error_not_found(); + } else if (err.status === 502) { + msg = m.storage_connect_error_server_error(); + } else { + msg = m.storage_connect_error(); + } + } else if (err instanceof Error) { + const e = (err.message ?? '').toLowerCase(); + if (/econnrefused|enotfound|eai_again|etimedout|network/.test(e)) { + msg = m.storage_connect_error_network(); + } else { + msg = m.storage_connect_error(); + } + } else { + msg = m.storage_connect_error(); + } + + return message(form, { type: 'error', message: msg }, { status: 400 }); } + const id = await saveConnection(locals.user!.id, config); + await auth.api.updateSession({ + headers: request.headers, + body: { activeStorageConnectionId: id } + }); + throw redirect(303, '/storage'); }, - disconnect: async ({ locals }) => { + disconnect: async ({ request, locals }) => { locals.logger.info('user storage connection cleared'); - throw redirect(303, '/storage?disconnected=1'); + await auth.api.updateSession({ + headers: request.headers, + body: { activeStorageConnectionId: null } + }); + throw redirect(303, '/storage'); + }, + + use: async ({ request, locals }) => { + const log = locals.logger; + const form = await superValidate(request, zod(ConnectionIdSchema)); + + if (!form.valid) { + return fail(400, { error: 'Invalid connection ID' }); + } + + const { connectionId } = form.data; + const userId = locals.user!.id; + + const config = await getConnectionForUser(userId, connectionId); + if (!config) { + return fail(400, { error: 'Connection not found' }); + } + + try { + await getConnectionProvider(config).listContainers(); + log.info({ connectionId }, 'user switched storage connection'); + } catch (err) { + log.warn({ err, connectionId }, 'storage connection test failed on use'); + return fail(400, { error: 'Could not connect — check the endpoint and credentials.' }); + } + + await auth.api.updateSession({ + headers: request.headers, + body: { activeStorageConnectionId: connectionId } + }); + + throw redirect(303, '/storage'); + }, + + deleteConnection: async ({ request, locals }) => { + const log = locals.logger; + const form = await superValidate(request, zod(ConnectionIdSchema)); + + if (!form.valid) { + return fail(400, { error: 'Invalid connection ID' }); + } + + const { connectionId } = form.data; + const userId = locals.user!.id; + const activeId = locals.session?.activeStorageConnectionId ?? null; + + if (connectionId === activeId) { + await auth.api.updateSession({ + headers: request.headers, + body: { activeStorageConnectionId: null } + }); + log.info({ connectionId }, 'active storage connection deleted, session cleared'); + } + + await deleteConnection(userId, connectionId); + + throw redirect(303, '/storage'); } }; diff --git a/src/routes/(app)/storage/+page.svelte b/src/routes/(app)/storage/+page.svelte index f0e99a79..b343a70a 100644 --- a/src/routes/(app)/storage/+page.svelte +++ b/src/routes/(app)/storage/+page.svelte @@ -1,23 +1,106 @@ -{#if data.connected} -
    +{#if data.connected && storage.connectionHostname} +
    {#if navigating?.to?.url.pathname.startsWith('/storage/')} {/if} -

    {m.storage_buckets_label()}

    -

    {m.storage_buckets_subtitle()}

    - - + {#if savedTabs} + + {/if} + +
    +
    +

    {m.storage_buckets_label()}

    +
    + + +
    +
    +

    {m.storage_buckets_subtitle()}

    +
    + +
    +
    + +
    + +{:else if data.connected || (data.hydrating && data.hasActiveConnection)} +
    +
    {:else if mounted} - + {/if} diff --git a/src/routes/(app)/storage/[bucket]/[...prefix]/+page.server.ts b/src/routes/(app)/storage/[bucket]/[...prefix]/+page.server.ts deleted file mode 100644 index 32a557c6..00000000 --- a/src/routes/(app)/storage/[bucket]/[...prefix]/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params }) => { - const prefix = params.prefix ? params.prefix + '/' : ''; - const bucket = params.bucket; - return { bucket, prefix }; -}; diff --git a/src/routes/(app)/storage/[bucket]/[...prefix]/+page.svelte b/src/routes/(app)/storage/[bucket]/[...prefix]/+page.svelte deleted file mode 100644 index 49c8f26b..00000000 --- a/src/routes/(app)/storage/[bucket]/[...prefix]/+page.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -{#if data.accessDenied} - -
    - - -
    -

    - {m.storage_error_title()} -

    -

    403

    -

    - {m.storage_error_access_denied({ bucket: data.bucket })} -

    -
    - -
    - - {m.storage_error_back_to_storage()} - - -
    -
    -{:else} - -{/if} diff --git a/src/routes/(app)/storage/[bucket]/[...prefix]/+page.ts b/src/routes/(app)/storage/[bucket]/[...prefix]/+page.ts deleted file mode 100644 index dcc0df33..00000000 --- a/src/routes/(app)/storage/[bucket]/[...prefix]/+page.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { browser } from '$app/environment'; -import { error, redirect } from '@sveltejs/kit'; -import { loadConnectionLocally, getConnectionHeader } from '$lib/storage/connection-storage.js'; -import type { PageLoad } from './$types'; -import type { StoragePage } from '$lib/storage/types.js'; - -const EMPTY_PAGE: StoragePage = { - objects: [], - hasNextPage: false, - currentPage: 1, - pageSize: 25 -}; - -/** - * Client-side load: fetches the object list from the server using the connection - * config from localStorage. Redirects to /storage if no connection is available. - * During SSR this returns an empty page — the load re-runs on the client. - */ -export const load: PageLoad = async ({ fetch, url, data }) => { - // Always pass server data through so PageData includes bucket/prefix. - const { bucket, prefix } = data; - - if (!browser) return { bucket, prefix, objects: EMPTY_PAGE }; - - const connection = loadConnectionLocally(); - if (!connection) throw redirect(303, '/storage'); - - const continuationToken = url.searchParams.get('continuationToken'); - const pageSizeParam = url.searchParams.get('pageSize'); - - const query = new URLSearchParams({ bucket, prefix: prefix ?? '' }); - if (continuationToken) query.set('continuationToken', continuationToken); - if (pageSizeParam) query.set('pageSize', pageSizeParam); - - const res = await fetch(`/api/storage/objects?${query}`, { - headers: { 'x-storage-connection': getConnectionHeader(connection) } - }); - - if (!res.ok) { - if (res.status === 401) throw redirect(303, '/storage'); - // For 403 we return an accessDenied flag rather than throwing error(). - // Throwing from a universal load during initial hydration (e.g. after - // page.goto) can bypass the +error.svelte boundary and fall through to the - // root fallback when data.connected=true causes BucketList to mount — a - // hydration-state mismatch that SvelteKit cannot safely recover from. - // Handling 403 inline in +page.svelte avoids the boundary entirely and - // keeps the sidebar visible so the user can navigate away. - if (res.status === 403) - return { bucket, prefix, objects: EMPTY_PAGE, accessDenied: true as const }; - const body = (await res.json().catch(() => ({}))) as { message?: string }; - throw error(res.status, body.message ?? 'Failed to load objects'); - } - - const objects = (await res.json()) as StoragePage; - return { bucket, prefix, objects }; -}; diff --git a/src/routes/(app)/storage/[bucket]/[...prefix]/page.server.test.ts b/src/routes/(app)/storage/[bucket]/[...prefix]/page.server.test.ts deleted file mode 100644 index 1ef2b010..00000000 --- a/src/routes/(app)/storage/[bucket]/[...prefix]/page.server.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { load } from './+page.server.js'; - -function mockEvent(opts: { bucket?: string; prefix?: string } = {}) { - return { - locals: { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, user: { id: 'test-user' } }, - params: { bucket: opts.bucket ?? 'my-bucket', prefix: opts.prefix ?? '' } - } as unknown as Parameters[0]; -} - -import { vi } from 'vitest'; - -describe('bucket page server load', () => { - it('returns bucket and prefix', async () => { - const result = await load(mockEvent()); - expect(result).toEqual({ bucket: 'my-bucket', prefix: '' }); - }); - - it('adds trailing slash to prefix', async () => { - const result = await load(mockEvent({ prefix: 'data/2024' })); - expect(result).toEqual({ bucket: 'my-bucket', prefix: 'data/2024/' }); - }); - - it('handles empty prefix', async () => { - const result = await load(mockEvent({ bucket: 'test-bucket', prefix: '' })); - expect(result).toEqual({ bucket: 'test-bucket', prefix: '' }); - }); -}); diff --git a/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.server.ts b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.server.ts new file mode 100644 index 00000000..16184f1b --- /dev/null +++ b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const activeId = locals.session?.activeStorageConnectionId ?? null; + if (!activeId) { + throw redirect(303, '/storage'); + } + const prefix = params.prefix ? params.prefix + '/' : ''; + const bucket = params.bucket; + const connection = params.connection; + return { connection, bucket, prefix, activeConnectionId: activeId }; +}; diff --git a/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.svelte b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.svelte new file mode 100644 index 00000000..0c8c3d47 --- /dev/null +++ b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.svelte @@ -0,0 +1,137 @@ + + + diff --git a/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.ts b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.ts new file mode 100644 index 00000000..e24e6e33 --- /dev/null +++ b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.ts @@ -0,0 +1,86 @@ +import { browser } from '$app/environment'; +import { error, isHttpError, redirect } from '@sveltejs/kit'; +import { connectionHostname, connectionStore } from '$lib/storage/connection-store.svelte.js'; +import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; +import * as m from '$lib/paraglide/messages.js'; +import type { PageLoad } from './$types'; +import type { StoragePage } from '$lib/storage/types.js'; + +const EMPTY_PAGE: StoragePage = { + objects: [], + hasNextPage: false, + currentPage: 1, + pageSize: 25 +}; + +/** + * Universal load: fetches the object list from the API. + * + * During SSR the fetch runs server-side so that permission errors (403) are + * caught by the SSR error boundary — this ensures our custom +error.svelte + * renders the full error page (breadcrumbs, icon, message, action buttons). + * On success during SSR we still return hydrating data; the client re-fetches + * and updates the page after hydration. + */ +export const load: PageLoad = async ({ fetch, url, data, parent }) => { + const { connection, bucket, prefix } = data; + + // The storage layout load populates `connectionStore` in the browser and + // provides the saved connections for the hostname check below. + const { connections } = await parent(); + + const connectionId = connectionStore.activeConnectionId ?? data.activeConnectionId; + if (!connectionId) throw redirect(303, '/storage'); + // A link to a storage the user is not connected to (e.g. from another + // connection's recent locations) must not list the active connection's bucket. + const active = connections.find((c) => c.id === connectionId); + if (!active) throw redirect(303, '/storage'); + if (connectionHostname(active) !== connection) { + throw error(404, { message: 'Not connected', reason: 'not-connected', connection }); + } + + const query = new URLSearchParams({ bucket, prefix: prefix ?? '' }); + + if (!browser) { + let res: Response; + try { + res = await fetch(`/api/storage/list?${query}`, { + headers: { [STORAGE_CONNECTION_ID_HEADER]: connectionId } + }); + } catch (err) { + if (isHttpError(err) && err.status === 403) { + throw error(403, m.storage_error_access_denied({ bucket })); + } + throw err; + } + + if (!res.ok) { + if (res.status === 401) throw redirect(303, '/storage'); + if (res.status === 403) throw error(403, m.storage_error_access_denied({ bucket })); + const body = (await res.json().catch(() => ({}))) as { message?: string }; + throw error(res.status, body.message ?? 'Failed to load objects'); + } + + return { connection, bucket, prefix, objects: EMPTY_PAGE, hydrating: true }; + } + + const continuationToken = url.searchParams.get('continuationToken'); + const pageSizeParam = url.searchParams.get('pageSize'); + + if (continuationToken) query.set('continuationToken', continuationToken); + if (pageSizeParam) query.set('pageSize', pageSizeParam); + + const res = await fetch(`/api/storage/list?${query}`, { + headers: { [STORAGE_CONNECTION_ID_HEADER]: connectionId } + }); + + if (!res.ok) { + if (res.status === 401) throw redirect(303, '/storage'); + if (res.status === 403) throw error(403, m.storage_error_access_denied({ bucket })); + const body = (await res.json().catch(() => ({}))) as { message?: string }; + throw error(res.status, body.message ?? 'Failed to load objects'); + } + + const objects = (await res.json()) as StoragePage; + return { connection, bucket, prefix, objects, hydrating: false }; +}; diff --git a/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/page.server.test.ts b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/page.server.test.ts new file mode 100644 index 00000000..df88db3d --- /dev/null +++ b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/page.server.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { load } from './+page.server.js'; + +function mockEvent( + opts: { + bucket?: string; + connection?: string; + prefix?: string; + activeConnectionId?: string | null; + session?: Record | null; + } = {} +) { + const session = + opts.session !== undefined + ? opts.session + : opts.activeConnectionId === undefined + ? { activeStorageConnectionId: null } + : { activeStorageConnectionId: opts.activeConnectionId }; + + return { + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + user: { id: 'test-user' }, + session + }, + params: { + connection: opts.connection ?? 's3.example.com', + bucket: opts.bucket ?? 'my-bucket', + prefix: opts.prefix ?? '' + } + } as unknown as Parameters[0]; +} + +describe('bucket page server load', () => { + it('returns bucket and prefix when an active connection exists', async () => { + const result = await load(mockEvent({ activeConnectionId: 'conn-123' })); + expect(result).toEqual({ + connection: 's3.example.com', + bucket: 'my-bucket', + prefix: '', + activeConnectionId: 'conn-123' + }); + }); + + it('redirects to /storage when no active connection', async () => { + await expect(load(mockEvent({ activeConnectionId: null }))).rejects.toThrow( + expect.objectContaining({ status: 303, location: '/storage' }) + ); + }); + + it('redirects to /storage when activeConnectionId is absent from session', async () => { + await expect(load(mockEvent({ session: null }))).rejects.toThrow( + expect.objectContaining({ status: 303, location: '/storage' }) + ); + }); + + it('adds trailing slash to prefix', async () => { + const result = await load(mockEvent({ prefix: 'data/2024', activeConnectionId: 'conn-123' })); + expect(result).toEqual({ + connection: 's3.example.com', + bucket: 'my-bucket', + prefix: 'data/2024/', + activeConnectionId: 'conn-123' + }); + }); + + it('handles empty prefix', async () => { + const result = await load( + mockEvent({ bucket: 'test-bucket', prefix: '', activeConnectionId: 'conn-123' }) + ); + expect(result).toEqual({ + connection: 's3.example.com', + bucket: 'test-bucket', + prefix: '', + activeConnectionId: 'conn-123' + }); + }); +}); diff --git a/src/routes/(app)/storage/connections/+page.server.ts b/src/routes/(app)/storage/connections/+page.server.ts deleted file mode 100644 index cf837762..00000000 --- a/src/routes/(app)/storage/connections/+page.server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PageServerLoad } from './$types'; - -// The connections list is managed entirely in localStorage on the client. -// The server only provides an empty shell so the route is authenticated. -export const load: PageServerLoad = async ({ locals }) => { - locals.logger.debug('loading storage connections management page'); - return {}; -}; diff --git a/src/routes/(app)/storage/connections/[id]/edit/+page.server.ts b/src/routes/(app)/storage/connections/[id]/edit/+page.server.ts deleted file mode 100644 index 875a06aa..00000000 --- a/src/routes/(app)/storage/connections/[id]/edit/+page.server.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { fail } from '@sveltejs/kit'; -import { superValidate, message } from 'sveltekit-superforms'; -import { zod4 as zod } from 'sveltekit-superforms/adapters'; -import type { Actions, PageServerLoad } from './$types'; -import type { Infer, InferIn } from 'sveltekit-superforms'; -import { - EditStorageConnectionSchema, - type StorageConnectionMessage -} from '$lib/storage/schemas.js'; -import { listBuckets } from '$lib/server/storage/service.js'; -import type { S3ConnectionConfig } from '$lib/server/storage/types.js'; -import * as m from '$lib/paraglide/messages.js'; - -type EditData = Infer; -type EditInput = InferIn; - -export const load: PageServerLoad = async ({ locals }) => { - const editForm = await superValidate( - { tls: { verification: 'Full' } }, - zod(EditStorageConnectionSchema), - { errors: false } - ); - locals.logger.debug('loading storage connection edit page'); - return { editForm }; -}; - -export const actions: Actions = { - update: async ({ request, locals }) => { - const log = locals.logger; - const form = await superValidate( - request, - zod(EditStorageConnectionSchema) - ); - - if (!form.valid) { - log.debug({ errors: form.errors }, 'storage connection edit form validation failed'); - return fail(400, { form }); - } - - const { type, host, port, tls, accessStyle, region, credentials } = form.data; - - if (type !== 's3') { - return message( - form, - { type: 'error', message: m.storage_connect_error_hdfs() }, - { status: 400 } - ); - } - - const resolvedCredentials = - credentials.accessKey && credentials.secretKey ? credentials : undefined; - - const config: S3ConnectionConfig = { - type: 's3', - host, - port, - tls, - accessStyle, - region, - credentials: resolvedCredentials - }; - - // If no credentials were submitted the user chose to keep the existing ones, - // so skip the live connection test (it was already verified when first saved). - if (resolvedCredentials) { - try { - await listBuckets(config); - log.info({ storage_type: type }, 'storage connection edit verified'); - } catch (err) { - log.warn({ err }, 'storage connection edit test failed'); - return message( - form, - { type: 'error', message: m.storage_connect_error() }, - { status: 400 } - ); - } - } - - return message(form, { type: 'success' }); - } -}; diff --git a/src/routes/(app)/storage/error.svelte.spec.ts b/src/routes/(app)/storage/error.svelte.spec.ts new file mode 100644 index 00000000..db5e9838 --- /dev/null +++ b/src/routes/(app)/storage/error.svelte.spec.ts @@ -0,0 +1,100 @@ +import { page } from 'vitest/browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +const mockPage = vi.hoisted(() => ({ + params: {} as Record, + url: new URL('http://localhost/storage'), + status: 500, + error: null as App.Error | null +})); + +vi.mock('$app/state', () => ({ page: mockPage })); + +import ErrorPage from './+error.svelte'; + +function setPage(opts: { + pathname: string; + params?: Record; + status: number; + message?: string; + error?: Partial; +}) { + mockPage.url = new URL(`http://localhost${opts.pathname}`); + mockPage.params = opts.params ?? {}; + mockPage.status = opts.status; + mockPage.error = { message: opts.message ?? 'Generic error', ...opts.error }; +} + +describe('/(app)/storage/+error.svelte', () => { + beforeEach(() => { + setPage({ pathname: '/storage', status: 500 }); + }); + + it('uses the bucket from route params when available', async () => { + setPage({ + pathname: '/storage/browse/conn-1/my-bucket/a/b', + params: { connection: 'conn-1', bucket: 'my-bucket', prefix: 'a/b' }, + status: 404 + }); + render(ErrorPage); + + await expect + .element(page.getByText('The bucket “my-bucket” does not exist.')) + .toBeInTheDocument(); + await expect.element(page.getByRole('link', { name: 'my-bucket' })).toBeInTheDocument(); + }); + + it('explains that a link points to a storage that is not connected', async () => { + setPage({ + pathname: '/storage/browse/other.example/my-bucket', + params: { connection: 'other.example', bucket: 'my-bucket' }, + status: 404, + error: { reason: 'not-connected', connection: 'other.example' } + }); + render(ErrorPage); + + await expect + .element( + page.getByText('You are not connected to the storage “other.example”.', { exact: false }) + ) + .toBeInTheDocument(); + expect(page.getByText('does not exist', { exact: false }).elements()).toHaveLength(0); + }); + + it('falls back to the bucket in the URL when route params are missing', async () => { + setPage({ + pathname: '/storage/browse/conn-1/my%20bucket/folder/sub', + params: {}, + status: 403 + }); + render(ErrorPage); + + await expect + .element(page.getByText('You do not have permission to access the bucket “my bucket”.')) + .toBeInTheDocument(); + const link = page.getByRole('link', { name: 'my bucket' }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute('href', '/storage/browse/conn-1/my%20bucket'); + await expect.element(page.getByText('folder/sub')).toBeInTheDocument(); + }); + + it('shows the server message and no bucket link when no bucket is known', async () => { + setPage({ pathname: '/storage', status: 404, message: 'Not found' }); + render(ErrorPage); + + await expect.element(page.getByText('Not found')).toBeInTheDocument(); + await expect + .element(page.getByRole('navigation', { name: 'breadcrumb' }).getByRole('link')) + .not.toBeInTheDocument(); + }); + + it('does not throw on malformed percent-encoding in the URL', async () => { + setPage({ pathname: '/storage/browse/conn-1/bad%E0bucket', params: {}, status: 404 }); + render(ErrorPage); + + await expect + .element(page.getByText('The bucket “bad%E0bucket” does not exist.')) + .toBeInTheDocument(); + }); +}); diff --git a/src/routes/(app)/storage/layout.server.test.ts b/src/routes/(app)/storage/layout.server.test.ts index 7edce12a..984aa2ea 100644 --- a/src/routes/(app)/storage/layout.server.test.ts +++ b/src/routes/(app)/storage/layout.server.test.ts @@ -7,6 +7,24 @@ vi.mock('$lib/server/feature-flags.js', () => ({ } })); +vi.mock('$lib/server/db.js', () => ({ + db: { + select: () => ({ from: () => ({ where: () => ({ orderBy: () => Promise.resolve([]) }) }) }) + } +})); + +vi.mock('$lib/server/storage/encryption.js', () => ({ + decrypt: vi.fn(() => JSON.stringify({ endpoint: 'https://s3.example.com' })) +})); + +vi.mock('$lib/server/storage/encryption-key.js', () => ({ + storageEncryptionKey: () => Buffer.alloc(32) +})); + +vi.mock('$lib/server/schema.js', () => ({ + userStorageConnections: {} +})); + import { load } from './+layout.server.js'; function mockEvent() { @@ -23,9 +41,14 @@ describe('storage layout server load', () => { await expect(load(mockEvent())).rejects.toThrow(expect.objectContaining({ status: 404 })); }); - it('returns disconnected default state', async () => { + it('returns disconnected default state with empty connections list', async () => { mockStorageBrowserEnabled.mockReturnValue(true); const result = await load(mockEvent()); - expect(result).toEqual({ connected: false, buckets: [], connectionType: null }); + expect(result).toMatchObject({ + connected: false, + buckets: [], + connections: [], + activeConnectionId: null + }); }); }); diff --git a/src/routes/(app)/storage/page.server.test.ts b/src/routes/(app)/storage/page.server.test.ts index 7f0d52a5..fe5c3d98 100644 --- a/src/routes/(app)/storage/page.server.test.ts +++ b/src/routes/(app)/storage/page.server.test.ts @@ -1,125 +1,263 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { stringify } from 'devalue'; -vi.mock('$lib/server/storage/service.js', () => ({ - listBuckets: vi.fn() +const mockConnectionProvider = { listContainers: vi.fn() }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getConnectionProvider: () => mockConnectionProvider })); -import { load, actions } from './+page.server.js'; -import { listBuckets } from '$lib/server/storage/service.js'; +vi.mock('$lib/storage/schemas.js', () => ({ + StorageConnectionSchema: {}, + ConnectionIdSchema: {} +})); -type LoadEvent = Parameters[0]; -type ConnectEvent = Parameters[0]; -type DisconnectEvent = Parameters[0]; +const { mockUpdateSession } = vi.hoisted(() => ({ + mockUpdateSession: vi.fn().mockResolvedValue({}) +})); +vi.mock('$lib/server/auth.js', () => ({ + auth: { api: { updateSession: mockUpdateSession } } +})); -function mockLocals() { - return { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, user: { id: 'test-user' } }; -} +const mockSaveConnection = vi.fn().mockResolvedValue('new-conn-id'); +const mockGetConnectionForUser = vi.fn(); +const mockDeleteConnection = vi.fn().mockResolvedValue(undefined); +vi.mock('$lib/server/storage/connections-db.js', () => ({ + saveConnection: (...args: unknown[]) => mockSaveConnection(...args), + getConnectionForUser: (...args: unknown[]) => mockGetConnectionForUser(...args), + deleteConnection: (...args: unknown[]) => mockDeleteConnection(...args) +})); -/** Build a POST request the way the client-side form sends it (`dataType: 'json'`). */ -function formRequest(data: Record): Request { - const body = new FormData(); - body.append('__superform_json', stringify(data)); - return new Request('http://localhost/storage?/connect', { method: 'POST', body }); -} +vi.mock('sveltekit-superforms', () => ({ + superValidate: vi.fn(), + message: vi.fn((form, msg, opts) => ({ form, message: msg, status: opts?.status })) +})); + +vi.mock('sveltekit-superforms/adapters', () => ({ + zod4: vi.fn((schema) => schema) +})); + +const mockDbInsert = vi.fn(); +const mockDbSelect = vi.fn().mockResolvedValue([]); +vi.mock('$lib/server/db.js', () => ({ + db: { + insert: () => ({ values: () => ({ returning: mockDbInsert }) }), + select: () => ({ + from: () => ({ where: () => ({ orderBy: mockDbSelect, limit: mockDbSelect }) }) + }) + } +})); -function connect(data: Record) { - return actions.connect({ - request: formRequest(data), - locals: mockLocals() - } as unknown as ConnectEvent); +vi.mock('$lib/server/storage/encryption.js', () => ({ + encrypt: vi.fn(() => 'encrypted-payload'), + fingerprint: vi.fn(() => 'fp-hash') +})); + +vi.mock('$lib/server/storage/encryption-key.js', () => ({ + storageEncryptionKey: () => Buffer.alloc(32) +})); + +vi.mock('$lib/server/schema.js', () => ({ + userStorageConnections: {} +})); + +import { load, actions } from './+page.server.js'; +import { superValidate } from 'sveltekit-superforms'; + +function mockLocals() { + return { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, user: { id: 'test-user' } }; } -const validS3 = { - type: 's3', - host: 's3.example.com', - accessStyle: 'Path', - region: { name: 'us-east-1' }, - credentials: { accessKey: 'ak', secretKey: 'sk' } +const validFormData = { + type: 's3' as const, + endpoint: 'http://s3', + pathStyle: true, + region: 'us-east-1', + accessKeyId: 'ak', + secretAccessKey: 'sk' }; describe('storage page load', () => { beforeEach(() => vi.clearAllMocks()); - it('returns an empty connection form with TLS verification enabled', async () => { - const result = await load({ locals: mockLocals() } as unknown as LoadEvent); - expect(result?.connectionForm.data.tls).toEqual({ verification: 'Full' }); - expect(result?.connectionForm.errors).toEqual({}); + it('returns connectionForm', async () => { + vi.mocked(superValidate).mockResolvedValue({ data: {} } as unknown as Awaited< + ReturnType + >); + + const result = (await load({ + locals: mockLocals() + } as unknown as Parameters[0])) as { connectionForm: unknown }; + expect(result.connectionForm).toBeDefined(); }); }); describe('storage page actions', () => { beforeEach(() => vi.clearAllMocks()); - it('connect: returns fail(400) with field errors on invalid form', async () => { - const result = await connect({ ...validS3, host: '' }); - expect(result).toMatchObject({ status: 400 }); - expect(result).toHaveProperty('data.form.errors.host'); - expect(listBuckets).not.toHaveBeenCalled(); - }); + it('connect: returns fail(400) on invalid form', async () => { + vi.mocked(superValidate).mockResolvedValue({ valid: false, errors: {} } as unknown as Awaited< + ReturnType + >); - it.each(['http://', 'http://:9000'])( - 'connect: rejects host %s that is empty after removing the scheme', - async (host) => { - const result = await connect({ ...validS3, host }); - expect(result).toMatchObject({ status: 400 }); - expect(result).toHaveProperty('data.form.errors.host'); - expect(listBuckets).not.toHaveBeenCalled(); - } - ); - - it('connect: rejects an access key without a secret key', async () => { - const result = await connect({ ...validS3, credentials: { accessKey: 'ak', secretKey: '' } }); - expect(result).toMatchObject({ status: 400 }); - expect(result).toHaveProperty('data.form.errors.credentials.secretKey'); - }); - - it('connect: strips the scheme from the host', async () => { - vi.mocked(listBuckets).mockResolvedValue(['b1']); - await expect(connect({ ...validS3, host: 'https://s3.example.com/' })).rejects.toMatchObject({ - status: 303 - }); - expect(listBuckets).toHaveBeenCalledWith(expect.objectContaining({ host: 's3.example.com' })); + const result = await actions.connect({ + request: new Request('http://localhost', { method: 'POST' }), + locals: mockLocals() + } as unknown as Parameters[0]); + expect(result?.status).toBe(400); }); it('connect: returns message for non-s3 type', async () => { - const result = await connect({ ...validS3, type: 'hdfs' }); - expect(result).toMatchObject({ status: 400 }); - expect(result).toHaveProperty('data.form.message', { + vi.mocked(superValidate).mockResolvedValue({ + valid: true, + data: { ...validFormData, type: 'hdfs' as const } + } as unknown as Awaited>); + + const result = await actions.connect({ + request: new Request('http://localhost', { method: 'POST' }), + locals: mockLocals() + } as unknown as Parameters[0]); + expect(result).toHaveProperty('message', { type: 'error', message: 'HDFS connections are not yet supported.' }); }); it('connect: returns error message on connection test failure', async () => { - vi.mocked(listBuckets).mockRejectedValue(new Error('connection refused')); + vi.mocked(superValidate).mockResolvedValue({ + valid: true, + data: { + type: 's3', + host: 's3.example.com', + accessStyle: 'Path', + region: { name: 'us-east-1' }, + credentials: { accessKey: 'ak', secretKey: 'sk' } + } + } as unknown as Awaited>); + mockConnectionProvider.listContainers.mockRejectedValue(new Error('connection refused')); - const result = await connect(validS3); - expect(result).toMatchObject({ status: 400 }); - expect(result).toHaveProperty('data.form.message', { + const result = await actions.connect({ + request: new Request('http://localhost', { method: 'POST' }), + locals: mockLocals() + } as unknown as Parameters[0]); + expect(result).toHaveProperty('message', { type: 'error', message: 'Could not connect — check the endpoint and credentials.' }); }); it('connect: redirects on success', async () => { - vi.mocked(listBuckets).mockResolvedValue(['b1']); - - await expect(connect(validS3)).rejects.toMatchObject({ status: 303, location: '/storage' }); - expect(listBuckets).toHaveBeenCalledWith({ - type: 's3', - host: 's3.example.com', - port: undefined, - tls: undefined, - accessStyle: 'Path', - region: { name: 'us-east-1' }, - credentials: { accessKey: 'ak', secretKey: 'sk' } - }); + vi.mocked(superValidate).mockResolvedValue({ + valid: true, + data: { + type: 's3', + host: 's3.example.com', + accessStyle: 'Path', + region: { name: 'us-east-1' }, + credentials: { accessKey: 'ak', secretKey: 'sk' } + } + } as unknown as Awaited>); + mockConnectionProvider.listContainers.mockResolvedValue(['b1']); + mockSaveConnection.mockResolvedValue('new-conn-id'); + + await expect( + actions.connect({ + request: new Request('http://localhost', { method: 'POST' }), + locals: mockLocals() + } as unknown as Parameters[0]) + ).rejects.toThrow(expect.objectContaining({ status: 303, location: '/storage' })); + }); + + it('disconnect: redirects to /storage', async () => { + await expect( + actions.disconnect({ + request: new Request('http://localhost', { method: 'POST' }), + locals: mockLocals() + } as unknown as Parameters[0]) + ).rejects.toThrow(expect.objectContaining({ status: 303, location: '/storage' })); + expect(mockUpdateSession).toHaveBeenCalledWith( + expect.objectContaining({ body: { activeStorageConnectionId: null } }) + ); + }); + + it('use: rejects an invalid connection form', async () => { + vi.mocked(superValidate).mockResolvedValue({ valid: false } as Awaited< + ReturnType + >); + + const result = await actions.use({ + request: new Request('http://localhost', { method: 'POST' }), + locals: mockLocals() + } as unknown as Parameters[0]); + + expect(result?.status).toBe(400); }); - it('disconnect: redirects', async () => { + it('use: rejects a connection that does not belong to the user', async () => { + vi.mocked(superValidate).mockResolvedValue({ + valid: true, + data: { connectionId: 'missing-connection' } + } as unknown as Awaited>); + mockGetConnectionForUser.mockResolvedValue(null); + + const result = await actions.use({ + request: new Request('http://localhost', { method: 'POST' }), + locals: mockLocals() + } as unknown as Parameters[0]); + + expect(result).toMatchObject({ status: 400, data: { error: 'Connection not found' } }); + }); + + it('use: sets the selected connection as active and redirects', async () => { + vi.mocked(superValidate).mockResolvedValue({ + valid: true, + data: { connectionId: 'saved-connection' } + } as unknown as Awaited>); + mockGetConnectionForUser.mockResolvedValue(validFormData); + mockConnectionProvider.listContainers.mockResolvedValue(['bucket']); + + await expect( + actions.use({ + request: new Request('http://localhost', { method: 'POST' }), + locals: mockLocals() + } as unknown as Parameters[0]) + ).rejects.toThrow(expect.objectContaining({ status: 303, location: '/storage' })); + expect(mockUpdateSession).toHaveBeenCalledWith( + expect.objectContaining({ body: { activeStorageConnectionId: 'saved-connection' } }) + ); + }); + + it('deleteConnection: clears the active session, deletes it, and redirects', async () => { + vi.mocked(superValidate).mockResolvedValue({ + valid: true, + data: { connectionId: 'active-connection' } + } as unknown as Awaited>); + const locals = { ...mockLocals(), session: { activeStorageConnectionId: 'active-connection' } }; + + await expect( + actions.deleteConnection({ + request: new Request('http://localhost', { method: 'POST' }), + locals + } as unknown as Parameters[0]) + ).rejects.toThrow(expect.objectContaining({ status: 303, location: '/storage' })); + expect(mockUpdateSession).toHaveBeenCalledWith( + expect.objectContaining({ body: { activeStorageConnectionId: null } }) + ); + expect(mockDeleteConnection).toHaveBeenCalledWith('test-user', 'active-connection'); + }); + + it('deleteConnection: deletes an inactive connection without clearing the session', async () => { + vi.mocked(superValidate).mockResolvedValue({ + valid: true, + data: { connectionId: 'inactive-connection' } + } as unknown as Awaited>); + const locals = { ...mockLocals(), session: { activeStorageConnectionId: 'active-connection' } }; + await expect( - actions.disconnect({ locals: mockLocals() } as unknown as DisconnectEvent) - ).rejects.toMatchObject({ status: 303, location: '/storage?disconnected=1' }); + actions.deleteConnection({ + request: new Request('http://localhost', { method: 'POST' }), + locals + } as unknown as Parameters[0]) + ).rejects.toThrow(expect.objectContaining({ status: 303, location: '/storage' })); + expect(mockUpdateSession).not.toHaveBeenCalled(); + expect(mockDeleteConnection).toHaveBeenCalledWith('test-user', 'inactive-connection'); }); }); diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index 94469750..2bb1145f 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -27,6 +27,7 @@ } from '$lib/editor/split-statements.js'; import type { PageData } from './$types'; import StatementResult from '$lib/components/trino/StatementResult.svelte'; + import { getLocale } from '$lib/paraglide/runtime.js'; let { data }: { data: PageData } = $props(); @@ -693,7 +694,9 @@ {m.trino_editor_label()} {#if charLimitReached} {m.trino_editor_char_limit_reached({ limit: MAX_SQL_LENGTH.toLocaleString() })}{m.trino_editor_char_limit_reached({ + limit: MAX_SQL_LENGTH.toLocaleString(getLocale()) + })} {/if}
    @@ -752,8 +755,9 @@ type="button" class=" btn join-item border-l-primary-content/20 btn-primary - self-stretch border-l px-2 + tooltip tooltip-bottom z-150 self-stretch border-l px-2 before:z-200 " + data-tip={m.trino_run_mode_select()} class:pointer-events-none={isActive} aria-haspopup="true" aria-label={m.trino_run_mode_select()} @@ -861,7 +865,7 @@ {#if runner.progress.processedRows > 0 || runner.progress.elapsedTimeMillis > 0} {m.trino_progress_info({ - rows: runner.progress.processedRows.toLocaleString(), + rows: runner.progress.processedRows.toLocaleString(getLocale()), elapsed: (runner.progress.elapsedTimeMillis / 1000).toFixed(1) })} diff --git a/src/routes/(app)/trino/query-runner.svelte.ts b/src/routes/(app)/trino/query-runner.svelte.ts index 82931bbf..d68beba6 100644 --- a/src/routes/(app)/trino/query-runner.svelte.ts +++ b/src/routes/(app)/trino/query-runner.svelte.ts @@ -153,7 +153,7 @@ function createQueryRunner(tabId: string): QueryRunner { } } catch (err) { if (signal.aborted) return; - console.error('Query poll failed', err); + void err; error = m.trino_query_connection_lost(); state = 'FAILED'; stopPolling(); @@ -198,7 +198,6 @@ function createQueryRunner(tabId: string): QueryRunner { if (!res.ok) { const body = await res.json().catch(() => null); - console.error('Query submit failed', body?.error ?? `HTTP ${res.status}`); error = body?.error ?? m.trino_query_connection_lost(); state = 'FAILED'; return; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6fdca625..a23186c9 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import '../app.css'; import { onMount } from 'svelte'; import { theme } from '$lib/theme.svelte'; + import NavigationProgress from '$lib/components/layout/NavigationProgress.svelte'; import * as m from '$lib/paraglide/messages.js'; let { children } = $props(); @@ -20,6 +21,7 @@ }); + diff --git a/src/test/TextEditorStub.svelte b/src/test/TextEditorStub.svelte new file mode 100644 index 00000000..1c0ff586 --- /dev/null +++ b/src/test/TextEditorStub.svelte @@ -0,0 +1,33 @@ + + + + diff --git a/src/test/setup-client.ts b/src/test/setup-client.ts index e01a15bc..bd25aa29 100644 --- a/src/test/setup-client.ts +++ b/src/test/setup-client.ts @@ -5,11 +5,17 @@ import { vi, beforeEach } from 'vitest'; // browser environment. Values match the defaults used in production when no // env vars are set. vi.mock('$lib/client/feature-flags.js', () => ({ - storageAutoConnectEnabled: false, + storageRestoreTabsEnabled: false, + infiniteScrollEnabled: true, allowedPageSizes: [25, 50, 100], defaultPageSize: 25, maxRecentFiles: 15, - uploadConcurrency: 3 + maxEditableFileSize: 5 * 1024 * 1024, + uploadConcurrency: 3, + storageCutCopyEnabled: false, + storagePasteEnabled: false, + storageRenameEnabled: false, + storageMoveEnabled: false })); // Prevent components from auto-submitting forms in browser tests. @@ -20,4 +26,9 @@ vi.mock('$lib/client/feature-flags.js', () => ({ // with a no-op for the duration of each test. beforeEach(() => { vi.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(() => {}); + + // Clear localStorage before each test to prevent BookmarksState (and any + // other localStorage-backed state) from leaking pinned locations, recent + // files, or other browser-storage data between tests. + localStorage.clear(); }); diff --git a/vite.config.ts b/vite.config.ts index b666c0d8..556a7e60 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ strategy: ['cookie', 'preferredLanguage', 'baseLocale'] }) ], + optimizeDeps: { + include: ['@sveltejs/kit', 'svelte'] + }, + server: { allowedHosts: true }, test: { coverage: { provider: 'v8', @@ -43,7 +47,7 @@ export default defineConfig({ instances: [{ browser: 'chromium', headless: true }] }, include: ['src/**/*.svelte.{test,spec}.{js,ts}'], - exclude: ['src/lib/server/**'], + exclude: ['src/lib/server/**', 'src/architecture-tests/**'], setupFiles: ['src/test/setup-client.ts'] } }, @@ -54,7 +58,7 @@ export default defineConfig({ name: 'server', environment: 'node', include: ['src/**/*.{test,spec}.{js,ts}'], - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}', 'src/architecture-tests/**'] } } ] diff --git a/vitest.arch.config.ts b/vitest.arch.config.ts new file mode 100644 index 00000000..54e53a58 --- /dev/null +++ b/vitest.arch.config.ts @@ -0,0 +1,24 @@ +/** + * Vitest configuration for architecture fitness functions. + * + * This is a separate configuration from the main vite.config.ts because + * ArchUnitTS requires `globals: true` to enable the `toPassAsync()` matcher. + * Enabling globals project-wide would mask missing imports in application code, + * so we keep this isolated. + * + * Run with: npm run test:arch + */ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/architecture-tests/**/*.spec.ts'], + reporters: ['verbose'], + // archunit's first run builds the entire file dependency graph from scratch. + // Subsequent tests use the cached graph and run in <10ms each. + // 30 seconds is generous even for large codebases. + testTimeout: 30000 + } +});