diff --git a/.env.example b/.env.example index 03b6ae69..6a85f21f 100644 --- a/.env.example +++ b/.env.example @@ -40,4 +40,14 @@ STACKABLE_COCKPIT_OIDC_CLIENT_SECRET=your-client-secret # 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) +# STACKABLE_COCKPIT_ARCHIVE_PREVIEW_MAX_MB=100 # Max decompressed size for archive preview in megabytes (default: 100) +# 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") +# PUBLIC_STACKABLE_COCKPIT_STORAGE_AUTO_CONNECT_TIMEOUT_MS=15000 # Auto-connect to last used storage after this many milliseconds (default: 15000) +# 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_MAX_EDITABLE_FILE_SIZE=1048576 # Max file size (in bytes) of editable text files in the storage browser (default: 5 MiB) # PUBLIC_STACKABLE_COCKPIT_UPLOAD_CONCURRENCY=3 # Maximum number of concurrent file uploads (default: 3) +# PUBLIC_STACKABLE_COCKPIT_INFINITE_SCROLL_ENABLED=true # Enable infinite scroll in the storage browser (default: true) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_CUT_COPY_ENABLED=true # Enables cut/copy/paste functionality in the storage browser (default: false) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_PASTE_ENABLED=true # Enables paste functionality in the storage browser (default: false) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_RENAME_ENABLED=true # Enables rename functionality in the storage browser (default: false) +# PUBLIC_STACKABLE_COCKPIT_STORAGE_MOVE_ENABLED=true # Enables move functionality in the storage browser (default: false) diff --git a/.env.test b/.env.test index c1b50cfe..1334b874 100644 --- a/.env.test +++ b/.env.test @@ -11,10 +11,21 @@ STACKABLE_COCKPIT_IMAGE_PREVIEW_BYTES=5242880 STACKABLE_COCKPIT_PDF_PREVIEW_BYTES=26214400 STACKABLE_COCKPIT_FILE_PREVIEW_ROWS=250 STACKABLE_COCKPIT_FILE_PREVIEW_COLUMNS=50 +STACKABLE_COCKPIT_PARQUET_PREVIEW_DISALLOWED_COMPRESSION_TYPES=gzip-no_offset PUBLIC_STACKABLE_COCKPIT_STORAGE_AUTO_CONNECT=true +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 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 +STORAGE_ENCRYPTION_KEY=a781775ee543107bfa97191691bb23c982dd9d9550542df27194ebc09d617dfd +PREVIEW_PORT=4173 diff --git a/.github/workflows/pr_checks.yaml b/.github/workflows/pr_checks.yaml index f1b7216b..399cd25d 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 @@ -85,10 +85,9 @@ jobs: - name: Run unit tests run: npm run test:unit - 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 @@ -107,35 +106,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..aaee646b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ src/lib/editor/generated/ .wrangler .svelte-kit /build +/reports/ # OS .DS_Store @@ -40,6 +41,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..32f8c50d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.13.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 239c27d2..1f71bd65 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,27 +43,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/ @@ -76,6 +94,6 @@ repos: --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] + stages: [pre-commit, pre-merge-commit, manual] pass_filenames: false files: ^deploy/helm/ diff --git a/.prettierignore b/.prettierignore index 6dbc7f7c..f2eaf48d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -33,3 +33,4 @@ coverage/ # Helm templates deploy/helm/ dev/garage/ +dev/postgresql/ diff --git a/AGENTS.md b/AGENTS.md index 68489856..a38c25b5 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,37 @@ 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/*.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, no raw ``, no hardcoded colours) +- 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 hardcoded Tailwind colours; no raw ``; no native date inputs; `` must have `alt`; no `
` | +| Server Logging | Server files must use pino logger, not `console.*` | +| 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/` 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 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..48679416 --- /dev/null +++ b/dev/lib/probe.sh @@ -0,0 +1,25 @@ +# 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" +} diff --git a/dev/modules/env.sh b/dev/modules/env.sh new file mode 100644 index 00000000..28f22f8e --- /dev/null +++ b/dev/modules/env.sh @@ -0,0 +1,90 @@ +# 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..be80b1ef --- /dev/null +++ b/dev/modules/postgresql.sh @@ -0,0 +1,28 @@ +# 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 +} + +postgresql::migrate() { + [[ "$SKIP_POSTGRESQL" == true ]] && return 0 + + log::info "Waiting for PostgreSQL to be ready..." + k8s::wait_for_pod app=postgresql 60 + + log::info "Running database migrations..." + ( + cd "$PROJECT_DIR" && + DATABASE_HOST=localhost \ + 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..8c64364a --- /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: localhost: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 + +- **Development** (`NODE_ENV != production`): SSL is **disabled** by default for local Kubernetes +- **Production** (`NODE_ENV = production`): SSL is **always enabled** for security + +This is configured automatically in both: + +- `drizzle.config.ts` - for migrations and Drizzle Studio +- `src/lib/server/db.ts` - for the application runtime + +**Never disable SSL in production.** Unencrypted database connections expose credentials and data to network attacks. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..522aab1f --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'drizzle-kit'; + +const isDev = 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: process.env.DATABASE_HOST ?? 'localhost', + port: Number(process.env.DATABASE_PORT ?? 31432), + database: process.env.DATABASE_NAME ?? 'cockpit', + user: process.env.DATABASE_USER ?? 'cockpit', + password: process.env.DATABASE_PASSWORD ?? 'cockpit-dev-password', + ssl: !isDev + } +}); diff --git a/e2e/database/db.spec.ts b/e2e/database/db.spec.ts new file mode 100644 index 00000000..8b9b287a --- /dev/null +++ b/e2e/database/db.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; +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..559c777b 100644 --- a/e2e/i18n.spec.ts +++ b/e2e/i18n.spec.ts @@ -17,9 +17,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'); 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..28d920af 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -5,8 +5,15 @@ 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..896f600a 100644 --- a/e2e/storage-s3.spec.ts +++ b/e2e/storage-s3.spec.ts @@ -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..9643c391 --- /dev/null +++ b/e2e/storage/add-bucket.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from '@playwright/test'; +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..9c51ea28 --- /dev/null +++ b/e2e/storage/archive.spec.ts @@ -0,0 +1,350 @@ +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { test, expect } from '@playwright/test'; +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 by double-clicking 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/zip' + }) + ); + + 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('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 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('button', { name: credentials.bucket }).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(page.getByText('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('button', { name: 'archive.zip' }).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/connection.spec.ts b/e2e/storage/connection.spec.ts index d74f1b7a..b4a1c7bf 100644 --- a/e2e/storage/connection.spec.ts +++ b/e2e/storage/connection.spec.ts @@ -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 126ac9ac..0d9ef3f0 100644 --- a/e2e/storage/connections-management.spec.ts +++ b/e2e/storage/connections-management.spec.ts @@ -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,14 +95,14 @@ 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 }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); - await page.goto('/storage/connections'); + await page.goto('/settings/connections'); await waitForHydration(page); await openFirstConnectionEditPage(page); @@ -112,9 +110,7 @@ test.describe('Storage — Connections management', () => { await page.getByLabel('Secret key').fill('wrong-secret'); await page.getByRole('button', { name: 'Save changes' }).click(); - await expect( - page.getByText('Could not connect — check the endpoint and credentials.') - ).toBeVisible(); + await expect(page.getByText('Access denied — check your credentials.')).toBeVisible(); }); test('delete button on management page removes the connection after confirmation', async ({ @@ -122,7 +118,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). @@ -141,11 +137,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 ({ @@ -153,7 +149,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); @@ -166,7 +162,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); @@ -175,7 +171,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(); @@ -185,13 +181,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(); @@ -202,17 +198,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/create.spec.ts b/e2e/storage/create.spec.ts new file mode 100644 index 00000000..7c374f0a --- /dev/null +++ b/e2e/storage/create.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { connectAndOpenPrefix, deleteKnownKeys, objectExists, 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 bucket 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(); + + // Wait for the file to appear in the listing + await page.waitForTimeout(1500); + + expect(await 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 bucket 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(); + + // Wait for the folder to appear in the listing + await page.waitForTimeout(1500); + + expect(await 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..d6b7ede8 100644 --- a/e2e/storage/delete.spec.ts +++ b/e2e/storage/delete.spec.ts @@ -119,4 +119,36 @@ 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('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('heading', { name: /Delete.*delete-me.txt/i })).not.toBeVisible(); + } 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..c1d1248b --- /dev/null +++ b/e2e/storage/details.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + connectAndOpenPrefix, + connectToStorage, + deleteKnownKeys, + 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('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 + } + }); +}); diff --git a/e2e/storage/file-operations.spec.ts b/e2e/storage/file-operations.spec.ts new file mode 100644 index 00000000..be1f554b --- /dev/null +++ b/e2e/storage/file-operations.spec.ts @@ -0,0 +1,484 @@ +import { test, expect } from '@playwright/test'; +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 page.waitForTimeout(500); + + // 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(/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 page.waitForTimeout(500); + + await page.locator('tbody').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Paste' }).click(); + + await page.waitForTimeout(1000); + + // 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 page.waitForTimeout(500); + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + await page.waitForTimeout(1000); + + expect(await 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 page.waitForTimeout(500); + await rowByName(page, 'd2').dblclick(); + await page.waitForTimeout(500); + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + await page.waitForTimeout(1000); + + expect(await objectExists(client, credentials.bucket, `${dest2}multi.txt`)).toBe(true); + } 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(); + + await page.waitForTimeout(1000); + + // Old name should be gone, new name should exist + expect(await 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('rename conflict creates a uniquely named file', 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 renamedFile = `${prefix}b (1).txt`; + const cleanupKeys = [fileA, fileB, renamedFile]; + + try { + await putTextObject(client, credentials.bucket, fileA, 'file a'); + await putTextObject(client, credentials.bucket, fileB, 'file b'); + + await connectAndOpenPrefix(page, credentials, prefix); + + // Right-click file A and choose rename + await rowByName(page, 'a.txt').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Rename' }).click(); + + // Rename to b.txt, preserving the existing file by assigning a unique name. + const input = page.locator('.modal-box input'); + await input.fill('b.txt'); + await page.getByRole('button', { name: 'Rename' }).click(); + + await expect(page.locator('.modal-box')).not.toBeVisible(); + await expect.poll(() => objectExists(client, credentials.bucket, fileA)).toBe(false); + await expect.poll(() => objectExists(client, credentials.bucket, fileB)).toBe(true); + await expect.poll(() => objectExists(client, credentials.bucket, renamedFile)).toBe(true); + } 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 page.waitForTimeout(300); + + // Navigate to parent prefix in-app (preserves clipboard) + await page.locator('tbody tr').first().click(); + await page.waitForTimeout(500); + + // Select somewhere to focus the page, then Ctrl+V + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + await page.waitForTimeout(1000); + + expect(await 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 page.waitForTimeout(1000); + + expect(await 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 cleanupKeys = [srcFile]; + + 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 page.waitForTimeout(300); + + // Navigate to parent prefix in-app (preserves clipboard) + await page.locator('tbody tr').first().click(); + await page.waitForTimeout(500); + + // Paste + await page.locator('table').click(); + await page.keyboard.press('Control+v'); + + await page.waitForTimeout(1000); + + // File should exist at destination + expect(await objectExists(client, credentials.bucket, `${prefix}cut-kb.txt`)).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(); + + await page.waitForTimeout(2000); + + // File should be moved to destination folder + expect(await objectExists(client, credentials.bucket, srcFile)).toBe(false); + expect(await objectExists(client, credentials.bucket, destFile)).toBe(true); + } 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(); + + await page.waitForTimeout(2000); + + // File should be at parent prefix + expect(await 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); + } + }); +}); diff --git a/e2e/storage/helpers.ts b/e2e/storage/helpers.ts index 104e76b6..da6f4c13 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,37 @@ 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); + + // A session update can redirect a just-opened bucket route to the storage + // overview after hydration. Retry once if that happens while loading. + for (let attempt = 0; attempt < 2; attempt += 1) { + await page.goto(route); + await waitForHydration(page); + + const objectsLoaded = waitForObjectsLoaded(page, 15_000).then(() => 'objects'); + const storageOverview = page + .getByRole('heading', { name: 'Buckets', exact: true }) + .waitFor({ timeout: 5_000 }) + .then(() => 'overview' as const) + // Keep waiting for the bucket list when the overview is not rendered. + .catch(() => new Promise(() => {})); + + let pageContent: 'objects' | 'overview' | undefined; + try { + pageContent = await Promise.race([objectsLoaded, storageOverview]); + } catch { + // A just-created session can briefly leave the browse request without a + // connection. Reconnect and retry the route rather than failing the test. + } + if (pageContent === 'objects' && page.url().includes(encodeURIComponent(credentials.bucket))) { + return; + } + + await connectToStorage(page, credentials); + } + await waitForObjectsLoaded(page); } @@ -113,15 +177,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 is empty')) .first() - .waitFor({ timeout: 15_000 }); + .waitFor({ timeout }); } /** @@ -218,3 +286,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/permissions.spec.ts b/e2e/storage/permissions.spec.ts index b9b2f467..c6af1cf2 100644 --- a/e2e/storage/permissions.spec.ts +++ b/e2e/storage/permissions.spec.ts @@ -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..744f1e7a 100644 --- a/e2e/storage/preview.spec.ts +++ b/e2e/storage/preview.spec.ts @@ -44,41 +44,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 +249,246 @@ 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 { + // A minimal valid parquet file generated by pyarrow: + // id | name | score + // 1 | Alice | 95.5 + // 2 | Bob | 87.0 + // 3 | Charlie | 72.3 + const parquetBase64 = + 'UEFSMRUEFSAVIEwVBBUAEgAAAQAAAAAAAAACAAAAAAAAABUAFRIVEiwVBBUQFQYVBhwYCAIAAAAAAAAAGAgBAAAAAAAAABYAKAgCAAAAAAAAABgIAQAAAAAAAAAREQAAAAIAAAAEAQEDAhUEFSAVIEwVBBUAEgAABQAAAEFsaWNlAwAAAEJvYhUAFRIVEiwVBBUQFQYVBhw2ACgDQm9iGAVBbGljZRERAAAAAgAAAAQBAQMCFQQVIBUgTBUEFQASAAAAAAAAAOBXQAAAAAAAwFVAFQAVEhUSLBUEFRAVBhUGHBgIAAAAAADgV0AYCAAAAAAAwFVAFgAoCAAAAAAA4FdAGAgAAAAAAMBVQBERAAAAAgAAAAQBAQMCFQQVEBUQTBUCFQASAAADAAAAAAAAABUAFRIVEiwVAhUQFQYVBhwYCAMAAAAAAAAAGAgDAAAAAAAAABYAKAgDAAAAAAAAABgIAwAAAAAAAAAREQAAAAIAAAACAQECABUEFRYVFkwVAhUAEgAABwAAAENoYXJsaWUVABUSFRIsFQIVEBUGFQYcNgAoB0NoYXJsaWUYB0NoYXJsaWUREQAAAAIAAAACAQECABUEFRAVEEwVAhUAEgAAMzMzMzMTUkAVABUSFRIsFQIVEBUGFQYcGAgzMzMzMxNSQBgIMzMzMzMTUkAWACgIMzMzMzMTUkAYCDMzMzMzE1JAEREAAAACAAAAAgEBAgAVBBlMNQAYBnNjaGVtYRUGABUEJQIYAmlkABUMJQIYBG5hbWUlAEwcAAAAFQolAhgFc2NvcmUAFgYZLBk8JgAcFQQZNQAGEBkYAmlkFQAWBBbMARbMASZEJggcGAgCAAAAAAAAABgIAQAAAAAAAAAWACgIAgAAAAAAAAAYCAEAAAAAAAAAEREAGSwVBBUAFQIAFQAVEBUCADwpBhkmAAQAAAAmABwVDBk1AAYQGRgEbmFtZRUAFgQWlAEWlAEmkAIm1AEcNgAoA0JvYhgFQWxpY2UREQAZLBUEFQAVAgAVABUQFQIAPBYQGQYZJgAEAAAAJgAcFQoZNQAGEBkYBXNjb3JlFQAWBBbMARbMASakAyboAhwYCAAAAAAA4FdAGAgAAAAAAMBVQBYAKAgAAAAAAOBXQBgIAAAAAADAVUAREQAZLBUEFQAVAgAVABUQFQIAPCkGGSYABAAAABasBBYEJggWrAQAGTwmABwVBBk1AAYQGRgCaWQVABYCFrwBFrwBJuAEJrQEHBgIAwAAAAAAAAAYCAMAAAAAAAAAFgAoCAMAAAAAAAAAGAgDAAAAAAAAABERABksFQQVABUCABUAFRAVAgA8KQYZJgACAAAAJgAcFQwZNQAGEBkYBG5hbWUVABYCFpYBFpYBJqIGJvAFHDYAKAdDaGFybGllGAdDaGFybGllEREAGSwVBBUAFQIAFQAVEBUCADwWDhkGGSYAAgAAACYAHBUKGTUABhAZGAVzY29yZRUAFgIWvAEWvAEmsgcmhgccGAgzMzMzMxNSQBgIMzMzMzMTUkAWACgIMzMzMzMTUkAYCDMzMzMzE1JAEREAGSwVBBUAFQIAFQAVEBUCADwpBhkmAAIAAAAWjgQWAia0BBaOBAAZHBgMQVJST1c6c2NoZW1hGLgCLy8vLy8rQUFBQUFRQUFBQUFBQUtBQXdBQmdBRkFBZ0FDZ0FBQUFBQkJBQU1BQUFBQ0FBSUFBQUFCQUFJQUFBQUJBQUFBQU1BQUFCNEFBQUFPQUFBQUFRQUFBQ2svLy8vQUFBQkF4QUFBQUFjQUFBQUJBQUFBQUFBQUFBRkFBQUFjMk52Y21VQUJnQUlBQVlBQmdBQUFBQUFBZ0RVLy8vL0FBQUJCUkFBQUFBY0FBQUFCQUFBQUFBQUFBQUVBQUFBYm1GdFpRQUFBQUFFQUFRQUJBQUFBQkFBRkFBSUFBWUFCd0FNQUFBQUVBQVFBQUFBQUFBQkFoQUFBQUFjQUFBQUJBQUFBQUFBQUFBQ0FBQUFhV1FBQUFnQURBQUlBQWNBQ0FBQUFBQUFBQUZBQUFBQUFBQUFBQT09ABggcGFycXVldC1jcHAtYXJyb3cgdmVyc2lvbiAyNC4wLjAZPBwAABwAABwAAAAUBAAAUEFSMQ=='; + + await client.send( + new PutObjectCommand({ + Bucket: credentials.bucket, + Key: `${prefix}data.parquet`, + Body: Buffer.from(parquetBase64, '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('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('closes preview modal when the Escape key is pressed', async ({ page }, testInfo) => { const credentials = requireGarageCredentials(); const client = createS3Client(credentials); diff --git a/e2e/storage/recent-items.spec.ts b/e2e/storage/recent-items.spec.ts index 9a53f2b7..b9b09357 100644 --- a/e2e/storage/recent-items.spec.ts +++ b/e2e/storage/recent-items.spec.ts @@ -5,21 +5,19 @@ import { 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,8 +88,7 @@ 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(); @@ -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,8 +130,7 @@ 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(); diff --git a/e2e/storage/tabs.spec.ts b/e2e/storage/tabs.spec.ts new file mode 100644 index 00000000..6685d0ac --- /dev/null +++ b/e2e/storage/tabs.spec.ts @@ -0,0 +1,356 @@ +import { test, expect } from '@playwright/test'; +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..86c76b10 --- /dev/null +++ b/e2e/storage/toast.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +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(); + await page.waitForTimeout(500); + + // 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..ebf6673f --- /dev/null +++ b/e2e/storage/tooltip.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; +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/upload.spec.ts b/e2e/storage/upload.spec.ts index 079f9816..bc91eabc 100644 --- a/e2e/storage/upload.spec.ts +++ b/e2e/storage/upload.spec.ts @@ -82,17 +82,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('button', { name: 'Close' }).first().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('button', { name: 'Close' }).first().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); } 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/db-migrations.setup.ts b/e2e/support/db-migrations.setup.ts new file mode 100644 index 00000000..d660fda8 --- /dev/null +++ b/e2e/support/db-migrations.setup.ts @@ -0,0 +1,17 @@ +import { test as setup } from '@playwright/test'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import fs from 'node:fs/promises'; +import path from 'path'; + +const stateFile = path.resolve('.playwright/postgres-state.json'); + +setup('run database migrations', async () => { + const raw = await fs.readFile(stateFile, 'utf-8'); + const { connectionUri } = JSON.parse(raw) as { connectionUri: string }; + + const db = drizzle(connectionUri); + await migrate(db, { + migrationsFolder: path.resolve('src/lib/server/migrations') + }); +}); 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 d9d992f0..9c9f433d 100644 --- a/e2e/support/global-setup.ts +++ b/e2e/support/global-setup.ts @@ -1,14 +1,21 @@ /* eslint-disable security/detect-non-literal-fs-filename */ import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; import path from 'path'; +import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import type { StartedTestContainer } from 'testcontainers'; +import { startPostgres } from './containers/postgres.setup.js'; +import { startGarage } from './containers/garage.setup.js'; + +const stateFile = path.resolve('.playwright/postgres-state.json'); + +let pgContainer: StartedPostgreSqlContainer; +let garageContainer: StartedTestContainer; 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'); @@ -26,11 +33,39 @@ export default async function globalSetup() { 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; - } + if (cfg.garageAdminUrl) process.env.GARAGE_ADMIN_URL = cfg.garageAdminUrl; + if (cfg.garageAdminToken) process.env.GARAGE_ADMIN_TOKEN = cfg.garageAdminToken; + } + + // When invoked via `npm run test:e2e`, the run-e2e.ts wrapper starts + // containers before spawning Playwright so that the webServer subprocess + // inherits DATABASE_* and S3_* env vars. Nothing to do here in that case. + if (process.env.TESTCONTAINERS_STARTED === 'true') { + return; } + + // Fallback for direct `playwright test` invocations (e.g. from the IDE). + // Note: in this path the webServer process is already running before + // containers are ready, so connection errors are possible unless + // reuseExistingServer is true and an existing server is already up. + pgContainer = await startPostgres(); + garageContainer = await startGarage(); + + await fsPromises.mkdir(path.dirname(stateFile), { recursive: true }); + await fsPromises.writeFile( + stateFile, + JSON.stringify({ + connectionUri: pgContainer.getConnectionUri() + }), + 'utf-8' + ); + + // Return a teardown function — runs in the main process after all tests, + // so we can call .stop() directly instead of shelling out to Docker CLI. + return async () => { + console.log('Tearing down testcontainers...'); + await Promise.allSettled([garageContainer.stop(), pgContainer.stop()]); + await fsPromises.rm(stateFile, { force: true }); + console.log('Testcontainers stopped.'); + }; } diff --git a/e2e/support/run-e2e.ts b/e2e/support/run-e2e.ts new file mode 100644 index 00000000..3998d3fa --- /dev/null +++ b/e2e/support/run-e2e.ts @@ -0,0 +1,67 @@ +/** + * Wrapper script that starts testcontainers BEFORE launching Playwright. + * + * Playwright's startup order is: + * 1. webServer plugin processes are spawned + * 2. globalSetup runs + * 3. Tests run + * + * This means env vars set in globalSetup are not inherited by the webServer + * subprocess. By starting the containers here — before `playwright test` is + * spawned — the child process inherits DATABASE_* and S3_* env vars and the + * SvelteKit app server can connect to the database on first query. + */ + +import { spawn } from 'node:child_process'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +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/postgres-state.json'); + +async function main() { + console.log('Starting test containers...'); + const [pgContainer, garageContainer] = await Promise.all([startPostgres(), startGarage()]); + + await fsPromises.mkdir(path.dirname(stateFile), { recursive: true }); + await fsPromises.writeFile( + stateFile, + JSON.stringify({ connectionUri: pgContainer.getConnectionUri() }), + 'utf-8' + ); + + // Signal to globalSetup that containers are already running so it skips + // the fallback startup path. + process.env.TESTCONTAINERS_STARTED = 'true'; + + let exitCode = 0; + try { + exitCode = await runPlaywright(process.argv.slice(2)); + } finally { + console.log('Tearing down testcontainers...'); + await Promise.allSettled([garageContainer.stop(), pgContainer.stop()]); + await fsPromises.rm(stateFile, { force: true }); + console.log('Testcontainers stopped.'); + } + + process.exit(exitCode); +} + +function runPlaywright(args: string[]): Promise { + return new Promise((resolve, reject) => { + const pw = spawn('npx', ['playwright', 'test', ...args], { + stdio: 'inherit', + env: process.env + }); + pw.on('error', reject); + pw.on('close', (code) => resolve(code ?? 0)); + }); +} + +main().catch((err) => { + console.error('Failed to run E2E tests:', err); + process.exit(1); +}); diff --git a/e2e/trino/trino-tabs.spec.ts b/e2e/trino/trino-tabs.spec.ts index 1dd01b26..6adb1f71 100644 --- a/e2e/trino/trino-tabs.spec.ts +++ b/e2e/trino/trino-tabs.spec.ts @@ -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/eslint.config.js b/eslint.config.js index 1893c1d0..28d0a3b1 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 occurance 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,127 @@ 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' + ] + } + ], + '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: 1100, 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', + 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 6173dea8..707cead7 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", @@ -147,13 +150,33 @@ "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", "storage_connect_forget": "Löschen", "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", @@ -169,7 +192,7 @@ "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", @@ -195,6 +218,16 @@ "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": "Ordner", "storage_folders": "Ordner", @@ -221,10 +254,13 @@ } ], "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_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.", @@ -236,10 +272,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.", @@ -262,10 +302,12 @@ "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_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", @@ -275,7 +317,7 @@ "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_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", @@ -321,58 +363,322 @@ "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_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_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_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_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_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_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_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_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_partial": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Element konnte nicht eingefügt werden.", + "countPlural=other": "{count} Elemente konnten nicht eingefügt werden." + } + } + ], + "storage_action_paste_error_source_not_found": "Einfügen fehlgeschlagen — die Quell-Elemente wurden möglicherweise gelöscht oder verschoben.", + "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_partial": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Element konnte nicht verschoben werden.", + "countPlural=other": "{count} Elemente konnten nicht verschoben werden." + } + } + ], + "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_more": "+{count} mehr", + "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_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_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.", "timestamp_just_now": "gerade eben", "timestamp_minutes_ago": [ { diff --git a/messages/en.json b/messages/en.json index b9216ce0..7a126d34 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", @@ -96,7 +99,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", @@ -147,13 +150,33 @@ "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", "storage_connect_forget": "Delete", "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", @@ -195,6 +218,16 @@ "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": "folder", "storage_folders": "folders", @@ -221,10 +254,13 @@ } ], "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_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.", @@ -236,10 +272,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.", @@ -262,10 +302,12 @@ "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_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", @@ -321,13 +363,30 @@ "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_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_context_menu_actions": "Actions", "storage_action_upload": "Upload", "storage_upload_title": "Upload", @@ -338,7 +397,7 @@ "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_conflicts_title": "Files already exist", "storage_upload_conflicts_desc": "Choose what to do for each conflicting file.", "storage_upload_skip_all": "Skip all", @@ -350,13 +409,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.", @@ -366,13 +425,260 @@ "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_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_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_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_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_partial": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 item could not be pasted.", + "countPlural=other": "{count} items could not be pasted." + } + } + ], + "storage_action_paste_error_source_not_found": "Could not paste — the source items may have been deleted or moved.", + "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_partial": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 item could not be moved.", + "countPlural=other": "{count} items could not be 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_more": "+{count} more", + "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_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_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.", "timestamp_just_now": "just now", "timestamp_minutes_ago": [ { diff --git a/package-lock.json b/package-lock.json index 24b34871..32ab4a24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,21 +7,27 @@ "": { "name": "@stackable/stackable-cockpit", "version": "0.0.1", - "license": "Apache-2.0", + "license": "OSL-3.0", "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.5.17", "antlr4-c3": "^3.4.4", "antlr4ng": "^3.0.16", - "better-auth": "^1.5.4", + "better-auth": "^1.6.20", + "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", + "tar-stream": "^3.2.0", "undici": "^7.24.4" }, "devDependencies": { @@ -29,21 +35,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.58.2", "@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.0.18", "@vitest/coverage-v8": "^4.0.18", "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 +75,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", @@ -68,8 +88,6 @@ }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", - "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", "dev": true, "license": "MIT", "dependencies": { @@ -82,8 +100,6 @@ }, "node_modules/@ark/schema": { "version": "0.56.0", - "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.0.tgz", - "integrity": "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==", "dev": true, "license": "MIT", "optional": true, @@ -93,16 +109,12 @@ }, "node_modules/@ark/util": { "version": "0.56.0", - "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.0.tgz", - "integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==", "dev": true, "license": "MIT", "optional": true }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -115,8 +127,6 @@ }, "node_modules/@aws-crypto/crc32c": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -126,8 +136,6 @@ }, "node_modules/@aws-crypto/sha1-browser": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", @@ -140,8 +148,6 @@ }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -155,8 +161,6 @@ }, "node_modules/@aws-crypto/sha256-js": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -169,8 +173,6 @@ }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -178,8 +180,6 @@ }, "node_modules/@aws-crypto/util": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -189,8 +189,6 @@ }, "node_modules/@aws-sdk/client-s3": { "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1053.0.tgz", - "integrity": "sha512-/oGxoB6p1Nqs935Blt+v1o+anSCEf2n3RjIrcLz84i4cn2Gr+Z7JpDdUkG5+74r5ctqEPG7k/phTGbJ9fNKnHg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", @@ -218,8 +216,6 @@ }, "node_modules/@aws-sdk/core": { "version": "3.974.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.13.tgz", - "integrity": "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.9", @@ -237,8 +233,6 @@ }, "node_modules/@aws-sdk/crc64-nvme": { "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.9.tgz", - "integrity": "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.2", @@ -250,8 +244,6 @@ }, "node_modules/@aws-sdk/credential-provider-env": { "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.39.tgz", - "integrity": "sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -266,8 +258,6 @@ }, "node_modules/@aws-sdk/credential-provider-http": { "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.41.tgz", - "integrity": "sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -284,8 +274,6 @@ }, "node_modules/@aws-sdk/credential-provider-ini": { "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.43.tgz", - "integrity": "sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -308,8 +296,6 @@ }, "node_modules/@aws-sdk/credential-provider-login": { "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.43.tgz", - "integrity": "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -325,8 +311,6 @@ }, "node_modules/@aws-sdk/credential-provider-node": { "version": "3.972.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.44.tgz", - "integrity": "sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.39", @@ -347,8 +331,6 @@ }, "node_modules/@aws-sdk/credential-provider-process": { "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.39.tgz", - "integrity": "sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -363,8 +345,6 @@ }, "node_modules/@aws-sdk/credential-provider-sso": { "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.43.tgz", - "integrity": "sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -381,8 +361,6 @@ }, "node_modules/@aws-sdk/credential-provider-web-identity": { "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz", - "integrity": "sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -398,8 +376,6 @@ }, "node_modules/@aws-sdk/lib-storage": { "version": "3.1053.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1053.0.tgz", - "integrity": "sha512-Y5fyrJ2Qln3lmU0I335no+zdyytpM7svvYOYadZiV2bXGDXEO26A8B+4iGW264GvBO4jnl/iPHVZ/hJIqXekAA==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.3", @@ -416,20 +392,8 @@ "@aws-sdk/client-s3": "^3.1053.0" } }, - "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } - }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.15.tgz", - "integrity": "sha512-O2HDANa+MrvbxpaRVQDiH3T13uAa9AkMjKyZmDygwauAmmvqZ5B0iRmKW+fuVGW6NPXuyXurFgIx69lSvmAWGA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -444,8 +408,6 @@ }, "node_modules/@aws-sdk/middleware-expect-continue": { "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.13.tgz", - "integrity": "sha512-sHiqIFg8o2ipT7t40B89Vj0ubSUtY6OSt/+Ee/OXhHch5K4+81zP2+QX8Lkc/nJ2QSmCySxOke7TEbmX69fe2g==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.9", @@ -459,8 +421,6 @@ }, "node_modules/@aws-sdk/middleware-flexible-checksums": { "version": "3.974.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.21.tgz", - "integrity": "sha512-alAu9heyiBK/OmRNXVxq8mmPTgeW2AQ6EYjRsI38kPZa1MZvt2Jh+BlGq7/GG9OVXOaEgD7DlGj/Lzfy5OmuEg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", @@ -479,8 +439,6 @@ }, "node_modules/@aws-sdk/middleware-location-constraint": { "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.11.tgz", - "integrity": "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.9", @@ -493,8 +451,6 @@ }, "node_modules/@aws-sdk/middleware-sdk-s3": { "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.42.tgz", - "integrity": "sha512-/xNqNGXv9LaxZd25L9VV4pnSOw9OdDNO4rAHamM+h3KQBSITljIH9vk3dveGga1I2j36lQd0rdG3gjNEXvtNew==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -511,8 +467,6 @@ }, "node_modules/@aws-sdk/middleware-ssec": { "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.11.tgz", - "integrity": "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.9", @@ -525,8 +479,6 @@ }, "node_modules/@aws-sdk/nested-clients": { "version": "3.997.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", - "integrity": "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -546,8 +498,6 @@ }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.28.tgz", - "integrity": "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.9", @@ -562,8 +512,6 @@ }, "node_modules/@aws-sdk/token-providers": { "version": "3.1052.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz", - "integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.13", @@ -579,8 +527,6 @@ }, "node_modules/@aws-sdk/types": { "version": "3.973.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", - "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.2", @@ -592,8 +538,6 @@ }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -604,8 +548,6 @@ }, "node_modules/@aws-sdk/xml-builder": { "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.25.tgz", - "integrity": "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==", "license": "Apache-2.0", "dependencies": { "@nodable/entities": "2.1.0", @@ -619,8 +561,6 @@ }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -628,9 +568,7 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -638,9 +576,7 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -648,9 +584,7 @@ }, "node_modules/@babel/parser": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -664,8 +598,6 @@ }, "node_modules/@babel/runtime": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "dev": true, "license": "MIT", "optional": true, @@ -675,9 +607,7 @@ }, "node_modules/@babel/types": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -687,68 +617,154 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "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", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@better-auth/utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz", - "integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==", - "license": "MIT" + "node_modules/@better-auth/core": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.20.tgz", + "integrity": "sha512-y73I1xNXuNYiHBFduWGRcJ2ro2rNuVDEYkgVMJtIaRXtbosdXHs9gfyQrHecgeHMHKx1SYSBT/CExak0vVMTng==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.6", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } }, - "node_modules/@better-fetch/fetch": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.20.tgz", + "integrity": "sha512-hJHfCdAiZrC7EmZAt3NAiGgcNo9Y5Qz3PLL+a9rODXaAJGCMvzUJniqef9wHuJBwU0SWW+2f4wXe8xQmaC/IKQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", - "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@chevrotain/gast": "10.5.0", - "@chevrotain/types": "10.5.0", - "lodash": "4.17.21" + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.20.tgz", + "integrity": "sha512-Uvpmgbx5y8JqXroVanNzDdKzOl3HojoTz+/X6MR6zOUr25IzlYz660mjnu0rxKiIF55kD3CroqFsDzjNUw7ERw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } } }, - "node_modules/@chevrotain/gast": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", - "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@chevrotain/types": "10.5.0", - "lodash": "4.17.21" + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.20.tgz", + "integrity": "sha512-J5Ni0LlFijbzXlwu2rFHaD8zEFocmajyzWkRnHsq8LhV/Dk4iWQwwnqzLrPoDQEj8roECAUF03hrIeMzqWRqJQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/utils": "0.4.2" } }, - "node_modules/@chevrotain/types": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", - "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", - "license": "Apache-2.0", - "peer": true + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.20.tgz", + "integrity": "sha512-ClDBJf6h4g85WJswxwQwxLaiyRU67Gmz/uaIf19tY1gqlLJDykSGjmqRNSBMG5rWABNzcNqbO4KG31rYUldbIw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } }, - "node_modules/@chevrotain/utils": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", - "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", - "license": "Apache-2.0", - "peer": true + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.20.tgz", + "integrity": "sha512-WhYdhSGuVSfu1peCSf2snmmVzfWjRaEvbSrsNCusiwGE9l94HlES4mjSPM48fed24hL7yg4j1dYK/yjEt87FpQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.20.tgz", + "integrity": "sha512-3BhbY3naQDERvdJvJ7fGszVY6rpsVfc6c9uyBVZlC1coVEF/rkM0rIcjtMVI1GUH7vWy1wjR6qF5vQnMun3XNQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" }, "node_modules/@cyclonedx/cyclonedx-npm": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-npm/-/cyclonedx-npm-4.2.1.tgz", - "integrity": "sha512-SOA/96sf0wsgUYCRtFkLFm6WoFhG+q1BxdC84hPSn9J3xWlH1e7OnTPJT+WNUzTqzX1nSm5JhjRX4krozu2X+g==", "dev": true, "funding": [ { @@ -781,8 +797,6 @@ }, "node_modules/@cyclonedx/cyclonedx-npm/node_modules/@cyclonedx/cyclonedx-library": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-library/-/cyclonedx-library-10.0.0.tgz", - "integrity": "sha512-xDXf2eqzeFHdjamj6oBV3duRSfrlmsJ5+2z9tXp7q5qxJP5Awmjf4ABSutS4qkVHHj7JzKFL/EM0V0Nihc7zPg==", "dev": true, "funding": [ { @@ -829,8 +843,6 @@ }, "node_modules/@cyclonedx/cyclonedx-npm/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, @@ -847,8 +859,6 @@ }, "node_modules/@cyclonedx/cyclonedx-npm/node_modules/commander": { "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", "engines": { @@ -857,58 +867,31 @@ }, "node_modules/@cyclonedx/cyclonedx-npm/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/@electric-sql/pglite": { - "version": "0.3.15", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", - "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@electric-sql/pglite-socket": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", - "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "pglite-server": "dist/scripts/server.js" - }, - "peerDependencies": { - "@electric-sql/pglite": "0.3.15" - } - }, - "node_modules/@electric-sql/pglite-tools": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", - "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", - "license": "Apache-2.0", - "peer": true, - "peerDependencies": { - "@electric-sql/pglite": "0.3.15" - } + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "devOptional": true, + "license": "Apache-2.0" }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "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.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "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, @@ -917,9 +900,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "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, @@ -927,1722 +910,1921 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "devOptional": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "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/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "node_modules/@esbuild-kit/core-utils/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": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "sunos" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "node_modules/@esbuild-kit/core-utils/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" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "node_modules/@esbuild-kit/core-utils/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": [ - "x64" + "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "sunos" + "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "node_modules/@esbuild-kit/core-utils/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": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "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", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.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": [ - "win32" + "aix" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "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": [ - "x64" + "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "android" ], "engines": { "node": ">=18" } }, - "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/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", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "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/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": "^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/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": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/compat": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.2.tgz", - "integrity": "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.0" - }, + "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": "^20.19.0 || ^22.13.0 || >=24" - }, - "peerDependencies": { - "eslint": "^8.40 || 9 || 10" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, + "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.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "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/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.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "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/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.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "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/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": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "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/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", - "dependencies": { - "mdn-data": "2.23.0", - "source-map-js": "^1.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, + "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", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "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": ">=18" } }, - "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-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" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, + "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.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=18" } }, - "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-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.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "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-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.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "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-x64": { + "version": "0.25.12", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "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, + "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 + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@faker-js/faker": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.4.0.tgz", - "integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } + "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": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", - "npm": ">=10" + "node": ">=18" } }, - "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/@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": ">=18" + } }, - "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/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, - "dependencies": { - "@hapi/hoek": "^9.0.0" + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "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", - "peer": true, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" + "node": ">=18" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", + "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.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "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/@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/@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/@eslint-community/eslint-utils": { + "version": "4.9.1", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=18.18.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=12.22" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@iconify-json/material-symbols": { - "version": "1.2.74", - "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.74.tgz", - "integrity": "sha512-GKJcvvm8A25pkh3Z2M430olvP4hDpDKg2sg7cpVnesM4bT7rExH74ThtRLWjdB86qhkxT3XrMuHRJa8LFCRmSw==", + "node_modules/@eslint/compat": { + "version": "2.0.2", "dev": true, "license": "Apache-2.0", "dependencies": { - "@iconify/types": "*" + "@eslint/core": "^1.1.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^8.40 || 9 || 10" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "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==", + "node_modules/@eslint/config-array": { + "version": "0.21.1", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@antfu/install-pkg": "^1.1.0", - "@iconify/types": "^2.0.0", - "import-meta-resolve": "^4.2.0" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@inlang/paraglide-js": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.12.0.tgz", - "integrity": "sha512-wnqTeSLcMMS2usL8zjS8bDGs9r16X00aeoGk2wVAnPfAgCChYalKdG20pS2XtJVMM1H6nBBBLKt3ZQMnKrusKQ==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@inlang/recommend-sherlock": "^0.2.1", - "@inlang/sdk": "^2.7.0", - "commander": "11.1.0", - "consola": "3.4.0", - "json5": "2.2.3", - "unplugin": "^2.1.2", - "urlpattern-polyfill": "^10.0.0" + "@eslint/core": "^0.17.0" }, - "bin": { - "paraglide-js": "bin/run.js" - } - }, - "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", - "dependencies": { - "comment-json": "^4.2.3" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@inlang/sdk": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.7.0.tgz", - "integrity": "sha512-yJNBD0o8i29TTJqWX5uDRHxnalDGcsUDctxepzFXsUfkzqGWfiFBxODdxvReqvM2CuKAAOo/kib/F1UcgdYFNQ==", + "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { + "version": "0.17.0", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@lix-js/sdk": "0.4.7", - "@sinclair/typebox": "^0.31.17", - "kysely": "^0.27.4", - "sqlite-wasm-kysely": "0.3.0", - "uuid": "^13.0.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@internationalized/date": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz", - "integrity": "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==", + "node_modules/@eslint/core": { + "version": "1.2.1", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@swc/helpers": "^0.5.0" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "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/css-tree": { + "version": "3.6.9", "dev": true, - "license": "ISC", - "optional": true, + "license": "MIT", "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" + "mdn-data": "2.23.0", + "source-map-js": "^1.0.1" }, "engines": { - "node": ">=12" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.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/eslintrc": { + "version": "3.3.3", "dev": true, - "license": "ISC", - "optional": true, + "license": "MIT", "dependencies": { - "minipass": "^7.0.4" + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "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, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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/js": { + "version": "9.39.2", + "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, - "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", + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "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", + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@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": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.7.tgz", - "integrity": "sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==", + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.17.0", "dev": true, "license": "Apache-2.0", "dependencies": { - "@lix-js/server-protocol-schema": "0.1.1", - "dedent": "1.5.1", - "human-id": "^4.1.1", - "js-sha256": "^0.11.0", - "kysely": "^0.27.4", - "sqlite-wasm-kysely": "0.3.0", - "uuid": "^10.0.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@lix-js/sdk/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@faker-js/faker": { + "version": "10.4.0", "dev": true, "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } ], "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" } }, - "node_modules/@lix-js/server-protocol-schema": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz", - "integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==", + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", - "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", - "license": "MIT", - "peer": true, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "sparse-bitfield": "^3.0.3" + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" } }, - "node_modules/@mrleebo/prisma-ast": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", - "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", - "license": "MIT", - "peer": true, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "chevrotain": "^10.5.0", - "lilconfig": "^2.1.0" + "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": ">=16" + "node": ">=6" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "node_modules/@hapi/hoek": { + "version": "9.3.0", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "dev": true, + "license": "BSD-3-Clause", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@hapi/hoek": "^9.0.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", + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 20.19.0" + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "engines": { + "node": ">=18.18.0" } }, - "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", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 20.19.0" + "node": ">=12.22" }, "funding": { - "url": "https://paulmillr.com/funding/" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, - "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/@humanwhocodes/retry": { + "version": "0.4.3", "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" - }, + "license": "Apache-2.0", "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" + "node": ">=18.18" }, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "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/@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, "license": "MIT", "dependencies": { - "@oozcitak/infra": "^2.0.2", - "@oozcitak/url": "^3.0.0", - "@oozcitak/util": "^10.0.0" - }, - "engines": { - "node": ">=20.0" + "@iconify/types": "*" } }, - "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/@iconify-json/material-icon-theme": { + "version": "1.2.67", + "resolved": "https://registry.npmjs.org/@iconify-json/material-icon-theme/-/material-icon-theme-1.2.67.tgz", + "integrity": "sha512-SouqLxahwVOuIVqED8Spl1wSy3DM7sYwNOPxFW7Eh4hVNqR4L4zTkEXFP4V6bQegkhl9kFWbNGlYKDFjXh92iQ==", "dev": true, "license": "MIT", "dependencies": { - "@oozcitak/util": "^10.0.0" - }, - "engines": { - "node": ">=20.0" + "@iconify/types": "*" } }, - "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/@iconify-json/material-symbols": { + "version": "1.2.74", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@oozcitak/infra": "^2.0.2", - "@oozcitak/util": "^10.0.0" - }, - "engines": { - "node": ">=20.0" + "@iconify/types": "*" } }, - "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/@iconify-json/vscode-icons": { + "version": "1.2.55", + "resolved": "https://registry.npmjs.org/@iconify-json/vscode-icons/-/vscode-icons-1.2.55.tgz", + "integrity": "sha512-7c6yep/OW7/xVWPedBeFbHv5wM4PnRcrZvlsaB+l5cBNWL/6thNE1yuN+PDPQZoEGdXRMUfwDAI3qF0ZVftwag==", "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" + "dependencies": { + "@iconify/types": "*" } }, - "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==", + "node_modules/@iconify/types": { + "version": "2.0.0", + "dev": true, "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/@iconify/utils": { + "version": "3.1.3", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" } }, - "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/paraglide-js": { + "version": "2.12.0", "dev": true, "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "dependencies": { + "@inlang/recommend-sherlock": "^0.2.1", + "@inlang/sdk": "^2.7.0", + "commander": "11.1.0", + "consola": "3.4.0", + "json5": "2.2.3", + "unplugin": "^2.1.2", + "urlpattern-polyfill": "^10.0.0" }, - "funding": { - "url": "https://opencollective.com/pkgr" + "bin": { + "paraglide-js": "bin/run.js" } }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "node_modules/@inlang/recommend-sherlock": { + "version": "0.2.1", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" + "comment-json": "^4.2.3" + } + }, + "node_modules/@inlang/sdk": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@lix-js/sdk": "0.4.7", + "@sinclair/typebox": "^0.31.17", + "kysely": "^0.27.4", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^13.0.0" }, "engines": { - "node": ">=18" + "node": ">=18.0.0" } }, - "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/@inlang/sdk/node_modules/kysely": { + "version": "0.27.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz", + "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", "dev": true, "license": "MIT", - "optional": true - }, - "node_modules/@prisma/client": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", - "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, "engines": { - "node": ">=16.13" - }, - "peerDependencies": { - "prisma": "*" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - } + "node": ">=14.0.0" } }, - "node_modules/@prisma/config": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.2.tgz", - "integrity": "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==", + "node_modules/@internationalized/date": { + "version": "3.11.0", "license": "Apache-2.0", - "peer": true, "dependencies": { - "c12": "3.1.0", - "deepmerge-ts": "7.1.5", - "effect": "3.18.4", - "empathic": "2.0.0" + "@swc/helpers": "^0.5.0" } }, - "node_modules/@prisma/config/node_modules/c12": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "license": "MIT", - "peer": true, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "license": "ISC", "dependencies": { - "chokidar": "^4.0.3", - "confbox": "^0.2.2", - "defu": "^6.1.4", - "dotenv": "^16.6.1", - "exsolve": "^1.0.7", - "giget": "^2.0.0", - "jiti": "^2.4.2", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "perfect-debounce": "^1.0.0", - "pkg-types": "^2.2.0", - "rc9": "^2.1.2" - }, - "peerDependencies": { - "magicast": "^0.3.5" + "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" }, - "peerDependenciesMeta": { - "magicast": { - "optional": true - } - } - }, - "node_modules/@prisma/config/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.4" }, - "funding": { - "url": "https://dotenvx.com" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@prisma/config/node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { - "@standard-schema/spec": "^1.0.0", - "fast-check": "^3.23.1" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@prisma/config/node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "devOptional": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@prisma/config/node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "devOptional": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=6.0.0" + } }, - "node_modules/@prisma/debug": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.2.tgz", - "integrity": "sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w==", - "license": "Apache-2.0", - "peer": true + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "devOptional": true, + "license": "MIT" }, - "node_modules/@prisma/dev": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", - "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", - "license": "ISC", - "peer": true, - "dependencies": { - "@electric-sql/pglite": "0.3.15", - "@electric-sql/pglite-socket": "0.0.20", - "@electric-sql/pglite-tools": "0.2.20", - "@hono/node-server": "1.19.9", - "@mrleebo/prisma-ast": "0.13.1", - "@prisma/get-platform": "7.2.0", - "@prisma/query-plan-executor": "7.2.0", - "foreground-child": "3.3.1", - "get-port-please": "3.2.0", - "hono": "4.11.4", - "http-status-codes": "2.3.0", - "pathe": "2.0.3", - "proper-lockfile": "4.1.2", - "remeda": "2.33.4", - "std-env": "3.10.0", - "valibot": "1.2.0", - "zeptomatch": "2.1.0" - } - }, - "node_modules/@prisma/engines": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.2.tgz", - "integrity": "sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "devOptional": true, + "license": "MIT", "dependencies": { - "@prisma/debug": "7.4.2", - "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", - "@prisma/fetch-engine": "7.4.2", - "@prisma/get-platform": "7.4.2" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@prisma/engines-version": { - "version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919.tgz", - "integrity": "sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz", - "integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@prisma/debug": "7.4.2" + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@prisma/fetch-engine": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.2.tgz", - "integrity": "sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA==", - "license": "Apache-2.0", - "peer": true, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "dev": true, + "license": "MIT", "dependencies": { - "@prisma/debug": "7.4.2", - "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", - "@prisma/get-platform": "7.4.2" + "debug": "^4.1.1" } }, - "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz", - "integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==", + "node_modules/@lix-js/sdk": { + "version": "0.4.7", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { - "@prisma/debug": "7.4.2" + "@lix-js/server-protocol-schema": "0.1.1", + "dedent": "1.5.1", + "human-id": "^4.1.1", + "js-sha256": "^0.11.0", + "kysely": "^0.27.4", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@prisma/get-platform": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", - "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@prisma/debug": "7.2.0" + "node_modules/@lix-js/sdk/node_modules/kysely": { + "version": "0.27.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz", + "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", - "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@prisma/query-plan-executor": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", - "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@prisma/studio-core": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", - "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", - "license": "Apache-2.0", - "peer": true, - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "node_modules/@lix-js/sdk/node_modules/uuid": { + "version": "10.0.0", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" } }, - "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==", + "node_modules/@lix-js/server-protocol-schema": { + "version": "0.1.1", + "dev": true, + "license": "Apache-2.0" + }, + "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, "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" + "@tybys/wasm-util": "^0.10.3" }, "engines": { - "node": ">=16.0.0 || 14 >= 14.17" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, - "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/@noble/ciphers": { + "version": "2.1.1", "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, "engines": { - "node": ">=14.0.0" + "node": ">= 20.19.0" }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "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/@nodable/entities": { + "version": "2.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "node": ">= 8" } }, - "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/@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": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "node": ">= 8" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], + "node_modules/@npmcli/agent": { + "version": "3.0.0", "dev": true, - "license": "MIT", + "license": "ISC", "optional": true, - "os": [ - "android" - ] + "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/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], + "node_modules/@npmcli/fs": { + "version": "4.0.0", "dev": true, - "license": "MIT", + "license": "ISC", "optional": true, - "os": [ - "android" - ] + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], + "node_modules/@oozcitak/dom": { + "version": "2.0.2", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], + "node_modules/@oozcitak/infra": { + "version": "2.0.2", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], + "node_modules/@oozcitak/url": { + "version": "3.0.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], + "node_modules/@oozcitak/util": { + "version": "10.0.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": ">=20.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=14" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "node_modules/@pkgr/core": { + "version": "0.2.9", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@poppinss/macroable": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.0", + "dev": 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" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "dev": 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 + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "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 + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "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 + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "android" ] }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { + "node_modules/@rollup/rollup-android-arm64": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "android" ] }, - "node_modules/@rollup/rollup-linux-arm64-musl": { + "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ] }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { + "node_modules/@rollup/rollup-darwin-x64": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ - "loong64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ] }, - "node_modules/@rollup/rollup-linux-loong64-musl": { + "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ - "loong64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ] }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ - "ppc64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ] }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2656,7 +2838,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2670,7 +2851,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2684,7 +2864,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2693,12 +2872,9 @@ }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2707,12 +2883,9 @@ }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2726,7 +2899,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2740,7 +2912,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2754,7 +2925,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2768,7 +2938,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2782,7 +2951,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2796,17 +2964,21 @@ "cpu": [ "x64" ], - "dev": true, "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/@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, @@ -2816,34 +2988,28 @@ }, "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.28", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", - "integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==", "dev": true, "license": "MIT" }, "node_modules/@smithy/core": { - "version": "3.28.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.28.0.tgz", - "integrity": "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz", + "integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.15.0", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -2852,8 +3018,6 @@ }, "node_modules/@smithy/credential-provider-imds": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", - "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.4", @@ -2866,8 +3030,6 @@ }, "node_modules/@smithy/fetch-http-handler": { "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", - "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.4", @@ -2880,8 +3042,6 @@ }, "node_modules/@smithy/is-array-buffer": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2906,8 +3066,6 @@ }, "node_modules/@smithy/signature-v4": { "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", - "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.4", @@ -2919,9 +3077,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", - "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", + "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2932,8 +3090,6 @@ }, "node_modules/@smithy/util-buffer-from": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -2945,8 +3101,6 @@ }, "node_modules/@smithy/util-utf8": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -2958,8 +3112,6 @@ }, "node_modules/@sqlite.org/sqlite-wasm": { "version": "3.48.0-build4", - "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz", - "integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2968,14 +3120,10 @@ }, "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.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -2984,8 +3132,6 @@ }, "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", "dependencies": { @@ -3000,8 +3146,6 @@ }, "node_modules/@sveltejs/kit": { "version": "2.53.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.2.tgz", - "integrity": "sha512-M+MqAvFve12T1HWws/2npP/s3hFtyjw3GB/OXW/8a1jZBk48qnvPJrtgE+VOMc3RnjUMxc4mv/vQ73nvj2uNMg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3042,8 +3186,6 @@ }, "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": { @@ -3063,8 +3205,6 @@ }, "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": { @@ -3081,8 +3221,6 @@ }, "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" @@ -3090,8 +3228,6 @@ }, "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": { @@ -3106,8 +3242,6 @@ }, "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", "engines": { @@ -3249,8 +3383,6 @@ }, "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" ], @@ -3266,8 +3398,6 @@ }, "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" ], @@ -3311,6 +3441,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": 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", @@ -3347,8 +3541,6 @@ }, "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": { @@ -3360,10 +3552,16 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testcontainers/postgresql": { + "version": "12.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^12.0.1" + } + }, "node_modules/@testing-library/svelte-core": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", - "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", "dev": true, "license": "MIT", "engines": { @@ -3374,9 +3572,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "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, @@ -3384,10 +3582,18 @@ "tslib": "^2.4.0" } }, + "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": { + "@types/node": "*" + } + }, "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", "dependencies": { @@ -3397,95 +3603,135 @@ }, "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", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "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.20.0", + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { - "csstype": "^3.2.2" + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.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==", "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "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", + "dependencies": { + "@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==", "devOptional": true, "license": "MIT" }, "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/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "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, @@ -3503,8 +3749,6 @@ }, "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, @@ -3519,8 +3763,6 @@ }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { @@ -3548,8 +3790,6 @@ }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -3558,8 +3798,6 @@ }, "node_modules/@typescript-eslint/parser": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", "dependencies": { @@ -3583,8 +3821,6 @@ }, "node_modules/@typescript-eslint/project-service": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3605,8 +3841,6 @@ }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, "license": "MIT", "dependencies": { @@ -3623,8 +3857,6 @@ }, "node_modules/@typescript-eslint/tsconfig-utils": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "dev": true, "license": "MIT", "engines": { @@ -3640,8 +3872,6 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "dev": true, "license": "MIT", "dependencies": { @@ -3665,8 +3895,6 @@ }, "node_modules/@typescript-eslint/types": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "dev": true, "license": "MIT", "engines": { @@ -3679,8 +3907,6 @@ }, "node_modules/@typescript-eslint/typescript-estree": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3707,8 +3933,6 @@ }, "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": { @@ -3716,9 +3940,7 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", "dev": true, "license": "MIT", "dependencies": { @@ -3730,8 +3952,6 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -3746,8 +3966,6 @@ }, "node_modules/@typescript-eslint/utils": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "dev": true, "license": "MIT", "dependencies": { @@ -3770,8 +3988,6 @@ }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, "license": "MIT", "dependencies": { @@ -3788,8 +4004,6 @@ }, "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": { @@ -3799,38 +4013,375 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@valibot/to-json-schema": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.5.0.tgz", - "integrity": "sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==", + "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", - "peerDependencies": { - "valibot": "^1.2.0" - } + "optional": true, + "os": [ + "android" + ] }, - "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/@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" + ], "dev": true, "license": "MIT", "optional": true, - "engines": { - "node": ">=18.0.0" - } + "os": [ + "android" + ] }, - "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/@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", "optional": true, - "dependencies": { - "@poppinss/macroable": "^1.0.4", - "@types/validator": "^13.12.2", - "@vinejs/compiler": "^3.0.0", + "os": [ + "darwin" + ] + }, + "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", + "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-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.5.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "valibot": "^1.2.0" + } + }, + "node_modules/@vinejs/compiler": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@vinejs/vine": { + "version": "3.0.1", + "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", @@ -3843,9 +4394,7 @@ }, "node_modules/@vitest/browser": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", - "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/mocker": "4.0.18", @@ -3866,9 +4415,7 @@ }, "node_modules/@vitest/browser-playwright": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz", - "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/browser": "4.0.18", @@ -3890,8 +4437,6 @@ }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", "dev": true, "license": "MIT", "dependencies": { @@ -3921,8 +4466,6 @@ }, "node_modules/@vitest/expect": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3939,8 +4482,6 @@ }, "node_modules/@vitest/mocker": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3966,8 +4507,6 @@ }, "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": { @@ -3976,8 +4515,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3989,8 +4526,6 @@ }, "node_modules/@vitest/runner": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4003,8 +4538,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4018,8 +4551,6 @@ }, "node_modules/@vitest/spy": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "devOptional": true, "license": "MIT", "funding": { @@ -4028,8 +4559,6 @@ }, "node_modules/@vitest/utils": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4040,10 +4569,21 @@ "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, @@ -4051,10 +4591,19 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "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": { @@ -4067,8 +4616,6 @@ }, "node_modules/acorn": { "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", "bin": { @@ -4080,29 +4627,32 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", "license": "MIT", - "optional": true, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "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": { @@ -4118,8 +4668,6 @@ }, "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, @@ -4137,8 +4685,6 @@ }, "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, @@ -4154,8 +4700,6 @@ }, "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, @@ -4172,19 +4716,14 @@ }, "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", - "optional": true, "engines": { "node": ">=12" }, @@ -4194,8 +4733,6 @@ }, "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": { @@ -4210,8 +4747,6 @@ }, "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": { @@ -4229,8 +4764,6 @@ }, "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": { @@ -4239,8 +4772,6 @@ }, "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" @@ -4248,21 +4779,181 @@ }, "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", + "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", + "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/buffer": { + "version": "6.0.3", + "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/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "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/buffer": { + "version": "6.0.3", + "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/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "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.3.3", + "resolved": "https://registry.npmjs.org/archunit/-/archunit-2.3.3.tgz", + "integrity": "sha512-dc2fCA1PqqHjofrWHk/cLaCVYTvIYtiDB2aQ62L1+LB/4Fw2J8zJRlyjoqVHC4b+MrXIzdq2uAFGNBfqDZrWuQ==", + "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.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/archunit/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "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": { @@ -4271,8 +4962,6 @@ }, "node_modules/arkregex": { "version": "0.0.5", - "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.5.tgz", - "integrity": "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==", "dev": true, "license": "MIT", "optional": true, @@ -4282,8 +4971,6 @@ }, "node_modules/arktype": { "version": "2.1.29", - "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.1.29.tgz", - "integrity": "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ==", "dev": true, "license": "MIT", "optional": true, @@ -4293,17 +4980,143 @@ "arkregex": "0.0.5" } }, + "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", + "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": { @@ -4312,8 +5125,6 @@ }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", "dev": true, "license": "MIT", "dependencies": { @@ -4324,74 +5135,179 @@ }, "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==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } }, + "node_modules/async": { + "version": "3.2.6", + "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", + "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/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "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", - "peer": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { - "node": ">= 6.0.0" + "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.8.1", + "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/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/bare-events": { + "version": "2.9.1", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "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.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "license": "Apache-2.0", + "dependencies": { + "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.4.5", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "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": { @@ -4401,27 +5317,35 @@ "node": ">= 0.8" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/better-auth": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.4.tgz", - "integrity": "sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.5.4", - "@better-auth/drizzle-adapter": "1.5.4", - "@better-auth/kysely-adapter": "1.5.4", - "@better-auth/memory-adapter": "1.5.4", - "@better-auth/mongo-adapter": "1.5.4", - "@better-auth/prisma-adapter": "1.5.4", - "@better-auth/telemetry": "1.5.4", - "@better-auth/utils": "0.3.1", - "@better-fetch/fetch": "1.1.21", + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.20.tgz", + "integrity": "sha512-fSpGHGRKiGRiYVd3QTQtuVZ8oxpiSe/7ip0Rpvt/Sy8zQbEbVKUPMOhE0gLXg+FjqTUsIo7582hxUYxtEcqUpA==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.20", + "@better-auth/drizzle-adapter": "1.6.20", + "@better-auth/kysely-adapter": "1.6.20", + "@better-auth/memory-adapter": "1.6.20", + "@better-auth/mongo-adapter": "1.6.20", + "@better-auth/prisma-adapter": "1.6.20", + "@better-auth/telemetry": "1.6.20", + "@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.3.2", + "better-call": "1.3.6", "defu": "^6.1.4", "jose": "^6.1.3", - "kysely": "^0.28.11", + "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, @@ -4433,7 +5357,7 @@ "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", - "drizzle-orm": ">=0.41.0", + "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", @@ -4506,114 +5430,13 @@ } } }, - "node_modules/better-auth/node_modules/@better-auth/core": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.5.4.tgz", - "integrity": "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "zod": "^4.3.6" - }, - "peerDependencies": { - "@better-auth/utils": "0.3.1", - "@better-fetch/fetch": "1.1.21", - "@cloudflare/workers-types": ">=4", - "better-call": "1.3.2", - "jose": "^6.1.0", - "kysely": "^0.28.5", - "nanostores": "^1.0.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/better-auth/node_modules/@better-auth/drizzle-adapter": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.5.4.tgz", - "integrity": "sha512-4M4nMAWrDd3TmpV6dONkJjybBVKRZghe5Oj0NNyDEoXubxastQdO7Sb5B54I1rTx5yoMgsqaB+kbJnu/9UgjQg==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "1.5.4", - "@better-auth/utils": "^0.3.0", - "drizzle-orm": ">=0.41.0" - } - }, - "node_modules/better-auth/node_modules/@better-auth/kysely-adapter": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.5.4.tgz", - "integrity": "sha512-DPww7rIfz6Ed7dZlJSW9xMQ42VKaJLB5Cs+pPqd+UHKRyighKjf3VgvMIcAdFPc4olQ0qRHo3+ZJhFlBCxRhxA==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "1.5.4", - "@better-auth/utils": "^0.3.0", - "kysely": "^0.27.0 || ^0.28.0" - } - }, - "node_modules/better-auth/node_modules/@better-auth/memory-adapter": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.5.4.tgz", - "integrity": "sha512-iiWYut9rbQqiAsgRBtj6+nxanwjapxRgpIJbiS2o81h7b9iclE0AiDA0Foes590gdFQvskNauZcCpuF8ytxthg==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "1.5.4", - "@better-auth/utils": "^0.3.0" - } - }, - "node_modules/better-auth/node_modules/@better-auth/mongo-adapter": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.5.4.tgz", - "integrity": "sha512-ArzJN5Obk6i6+vLK1HpPzLIcsjxZYXPPUvxVU8eyU5HyoUT2MlswWfPQ8UJAKPn0iq/T4PVp/wZcQMhWk1tuNA==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "1.5.4", - "@better-auth/utils": "^0.3.0", - "mongodb": "^6.0.0 || ^7.0.0" - } - }, - "node_modules/better-auth/node_modules/@better-auth/prisma-adapter": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.5.4.tgz", - "integrity": "sha512-ZQTbcBopw/ezjjbNFsfR3CRp0QciC4tJCarAnB5G9fZtUYbDjfY0vZOxIRmU4kI3x755CXQpGqTrkwmXaMRa3w==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "1.5.4", - "@better-auth/utils": "^0.3.0", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/better-auth/node_modules/@better-auth/telemetry": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.5.4.tgz", - "integrity": "sha512-mGXTY7Ecxo7uvlMr6TFCBUvlH0NUMOeE9LKgPhG4HyhBN6VfCEg/DD9PG0Z2IatmMWQbckkt7ox5A0eBpG9m5w==", - "license": "MIT", - "dependencies": { - "@better-auth/utils": "0.3.1", - "@better-fetch/fetch": "1.1.21" - }, - "peerDependencies": { - "@better-auth/core": "1.5.4" - } - }, - "node_modules/better-auth/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/better-call": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.2.tgz", - "integrity": "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.6.tgz", + "integrity": "sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA==", "license": "MIT", "dependencies": { - "@better-auth/utils": "^0.3.1", + "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" @@ -4629,8 +5452,6 @@ }, "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, @@ -4640,8 +5461,6 @@ }, "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": { @@ -4650,7 +5469,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -4659,8 +5477,6 @@ }, "node_modules/body-parser": { "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "dev": true, "license": "MIT", "dependencies": { @@ -4684,14 +5500,10 @@ }, "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.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", "dev": true, "license": "MIT", "dependencies": { @@ -4699,46 +5511,58 @@ "concat-map": "0.0.1" } }, - "node_modules/bson": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", - "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", - "license": "Apache-2.0", - "peer": true, + "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": ">=20.19.0" + "node": ">=8" } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "5.6.0", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", "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", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "devOptional": true, + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "dev": true, "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/byline": { + "version": "5.0.0", + "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": { @@ -4747,8 +5571,6 @@ }, "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, @@ -4770,10 +5592,27 @@ "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": { @@ -4786,8 +5625,6 @@ }, "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": { @@ -4803,8 +5640,6 @@ }, "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": { @@ -4813,8 +5648,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, @@ -4827,8 +5660,6 @@ }, "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": { @@ -4837,8 +5668,6 @@ }, "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": { @@ -4852,25 +5681,9 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chevrotain": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", - "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@chevrotain/cst-dts-gen": "10.5.0", - "@chevrotain/gast": "10.5.0", - "@chevrotain/types": "10.5.0", - "@chevrotain/utils": "10.5.0", - "lodash": "4.17.21", - "regexp-to-ast": "0.5.0" - } - }, "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" @@ -4884,26 +5697,11 @@ }, "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/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "consola": "^3.2.3" - } + "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, @@ -4913,92 +5711,193 @@ "validator": "^13.15.22" } }, - "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==", + "node_modules/cliui": { + "version": "8.0.1", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "color-name": "~1.1.4" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=12" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", "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==", + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=16" + "node": ">=8" } }, - "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==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", "dev": true, "license": "MIT", "dependencies": { - "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", - "esprima": "^4.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 6" + "node": ">=8" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "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", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/comment-json": { + "version": "4.5.1", + "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", "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "6.0.2", + "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/buffer": { + "version": "6.0.3", + "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/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "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" @@ -5006,8 +5905,6 @@ }, "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": { @@ -5020,8 +5917,6 @@ }, "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": { @@ -5030,8 +5925,6 @@ }, "node_modules/cookie": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "devOptional": true, "license": "MIT", "engines": { @@ -5040,8 +5933,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": { @@ -5050,15 +5941,11 @@ }, "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": { @@ -5073,10 +5960,83 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "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", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "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/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "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", @@ -5089,8 +6049,6 @@ }, "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": { @@ -5100,27 +6058,70 @@ "node": ">=4" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true - }, "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": { @@ -5129,16 +6130,12 @@ }, "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": { @@ -5155,8 +6152,6 @@ }, "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, @@ -5172,8 +6167,6 @@ }, "node_modules/dedent": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", - "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5187,8 +6180,6 @@ }, "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, @@ -5198,69 +6189,68 @@ }, "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/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "license": "BSD-3-Clause", - "peer": true, + "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": ">=16.0.0" + "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.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=0.10" - } - }, "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/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "license": "MIT", - "peer": true - }, "node_modules/detect-libc": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5268,57 +6258,115 @@ }, "node_modules/devalue": { "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", "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/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" + "node_modules/docker-compose": { + "version": "1.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" } }, - "node_modules/drizzle-orm": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.41.0.tgz", - "integrity": "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==", + "node_modules/docker-modem": { + "version": "5.0.7", + "dev": true, "license": "Apache-2.0", - "peer": true, - "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", + "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.0", + "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.2.7", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "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-orm": { + "version": "0.45.2", + "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", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=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", @@ -5376,6 +6424,9 @@ "@types/sql.js": { "optional": true }, + "@upstash/redis": { + "optional": true + }, "@vercel/postgres": { "optional": true }, @@ -5422,8 +6473,6 @@ }, "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": { @@ -5435,25 +6484,54 @@ "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/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", - "optional": 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.21.2", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.2.tgz", - "integrity": "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==", + "version": "3.21.3", "dev": true, "license": "MIT", "optional": true, @@ -5464,26 +6542,11 @@ }, "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/empathic": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14" - } + "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": { @@ -5492,8 +6555,6 @@ }, "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, @@ -5503,8 +6564,6 @@ }, "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, @@ -5517,8 +6576,6 @@ }, "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": { @@ -5527,8 +6584,6 @@ }, "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": { @@ -5541,8 +6596,6 @@ }, "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, @@ -5552,16 +6605,100 @@ }, "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": { @@ -5570,8 +6707,6 @@ }, "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": { @@ -5580,15 +6715,13 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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": { @@ -5598,497 +6731,122 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "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, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, - "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" - ], - "dev": true, - "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" - ], - "dev": true, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "node": ">= 0.4" } }, - "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" - ], + "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", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "hasown": "^2.0.2" + }, "engines": { - "node": ">=18" + "node": ">= 0.4" } }, - "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" - ], + "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", - "optional": true, - "os": [ - "darwin" - ], + "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": ">=18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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" - ], - "dev": true, + "node_modules/esbuild": { + "version": "0.25.12", + "devOptional": true, + "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { "node": ">=18" - } - }, - "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" - ], + }, + "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/escalade": { + "version": "3.2.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": ">=6" } }, - "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/escape-html": { + "version": "1.0.3", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "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/escape-string-regexp": { + "version": "4.0.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "node_modules/eslint": { + "version": "9.39.2", "dev": true, "license": "MIT", "dependencies": { @@ -6147,8 +6905,6 @@ }, "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", "bin": { @@ -6161,66 +6917,305 @@ "eslint": ">=7.0.0" } }, - "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/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": { - "@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" + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23.0.0" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" }, "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" + "unrs-resolver": "^1.0.0" }, "peerDependenciesMeta": { - "eslint": { - "optional": true - }, - "oxlint": { + "unrs-resolver": { "optional": true } } }, - "node_modules/eslint-plugin-security": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.0.0.tgz", - "integrity": "sha512-tfuQT8K/Li1ZxhFzyD8wPIKtlzZxqBcPr9q0jFMQ77wWAbKBVEhaMPVQRTMTvCMUDhwBe5vPVqQPwAGk/ASfxQ==", + "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": "Apache-2.0", + "license": "MIT", "dependencies": { - "safe-regex": "^2.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, - "node_modules/eslint-plugin-svelte": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.0.tgz", - "integrity": "sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==", + "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", "dependencies": { - "@eslint-community/eslint-utils": "^4.6.1", - "@jridgewell/sourcemap-codec": "^1.5.0", - "esutils": "^2.0.3", - "globals": "^16.0.0", + "ms": "^2.1.1" + } + }, + "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": { + "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.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": "ISC", + "dependencies": { + "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": "^16.17.0 || >=18.6.0" + }, + "funding": { + "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/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": ">=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", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-better-tailwindcss": { + "version": "4.3.0", + "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_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, + "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": { + "is-glob": "^4.0.3", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "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", + "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" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "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", + "dependencies": { + "ms": "^2.1.1" + } + }, + "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", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "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": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/eslint-plugin-security": { + "version": "4.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.15.0", + "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", @@ -6246,8 +7241,6 @@ }, "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": { @@ -6263,8 +7256,6 @@ }, "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": "Apache-2.0", "engines": { @@ -6276,8 +7267,6 @@ }, "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": "Apache-2.0", "dependencies": { @@ -6289,15 +7278,11 @@ }, "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==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6314,8 +7299,6 @@ }, "node_modules/esprima": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", "bin": { @@ -6328,8 +7311,6 @@ }, "node_modules/esquery": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6341,8 +7322,6 @@ }, "node_modules/esrap": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", - "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -6351,8 +7330,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6364,8 +7341,6 @@ }, "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": { @@ -6374,15 +7349,11 @@ }, "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": { @@ -6391,27 +7362,36 @@ }, "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", "engines": { "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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/events-universal": { + "version": "1.0.1", + "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, @@ -6421,8 +7401,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": { @@ -6431,16 +7409,12 @@ }, "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": { @@ -6483,8 +7457,6 @@ }, "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==", "dev": true, "license": "MIT", "engines": { @@ -6493,22 +7465,18 @@ }, "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", @@ -6520,6 +7488,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "pure-rand": "^6.1.0" }, @@ -6529,36 +7498,60 @@ }, "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==", "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fast-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": { @@ -6567,15 +7560,11 @@ }, "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.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -6592,8 +7581,6 @@ }, "node_modules/fast-xml-builder": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -6608,8 +7595,6 @@ }, "node_modules/fast-xml-parser": { "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", "funding": [ { "type": "github", @@ -6627,10 +7612,18 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/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": { @@ -6647,8 +7640,6 @@ }, "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", "dependencies": { @@ -6660,16 +7651,25 @@ }, "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 }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/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", "dependencies": { @@ -6690,8 +7690,6 @@ }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -6707,8 +7705,6 @@ }, "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", "dependencies": { @@ -6721,15 +7717,28 @@ }, "node_modules/flatted": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "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", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "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", @@ -6744,8 +7753,6 @@ }, "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": { @@ -6754,8 +7761,6 @@ }, "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", "engines": { @@ -6767,13 +7772,10 @@ "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", - "optional": 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, @@ -6788,7 +7790,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6801,47 +7802,28 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/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/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-property": "^1.0.2" - } - }, - "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/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": { - "call-bind-apply-helpers": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "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" + "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": ">= 0.4" @@ -6850,17 +7832,74 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-port-please": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", - "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "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", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fzstd": { + "version": "0.1.1", + "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", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port": { + "version": "7.2.0", + "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6871,40 +7910,58 @@ "node": ">= 0.4" } }, - "node_modules/giget": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "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": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "MIT", - "peer": true, "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.6.0", - "pathe": "^2.0.3" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" }, - "bin": { - "giget": "dist/cli.mjs" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, "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", - "optional": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -6922,8 +7979,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -6934,23 +7989,17 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "balanced-match": "^1.0.0" } }, "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": "ISC", - "optional": true, "dependencies": { "brace-expansion": "^2.0.2" }, @@ -6963,8 +8012,6 @@ }, "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": { @@ -6974,10 +8021,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": ">= 0.4" + }, + "funding": { + "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==", "dev": true, "license": "MIT", "engines": { @@ -6989,38 +8051,61 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, - "node_modules/grammex": { - "version": "3.1.12", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", - "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", - "license": "MIT", - "peer": true - }, - "node_modules/graphmatch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", - "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "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", - "peer": true + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "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==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "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", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -7030,10 +8115,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -7045,8 +8146,6 @@ }, "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, "license": "MIT", "bin": { @@ -7055,25 +8154,11 @@ }, "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/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, "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": "ISC", "dependencies": { @@ -7084,9 +8169,7 @@ } }, "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==", + "version": "11.5.1", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7095,23 +8178,17 @@ }, "node_modules/html-escaper": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, "node_modules/http-cache-semantics": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "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==", "dev": true, "license": "MIT", "dependencies": { @@ -7131,8 +8208,6 @@ }, "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, @@ -7144,17 +8219,8 @@ "node": ">= 14" } }, - "node_modules/http-status-codes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "license": "MIT", - "peer": true - }, "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, @@ -7168,8 +8234,6 @@ }, "node_modules/human-id": { "version": "4.1.3", - "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", - "integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==", "dev": true, "license": "MIT", "bin": { @@ -7178,14 +8242,10 @@ }, "node_modules/hyparquet": { "version": "1.26.0", - "resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.26.0.tgz", - "integrity": "sha512-yxUiViPZ+z5h+xdX4rA1G+k30jXoEsG9I2xEpjaM84imGznbKjZzxuZFsdzqg6C4LxNnnAlDFvzpk4uxQWTbTQ==", "license": "MIT" }, "node_modules/hyparquet-compressors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/hyparquet-compressors/-/hyparquet-compressors-1.1.1.tgz", - "integrity": "sha512-yx7aA3Rhj0YycbdV71+XznQSLAefa4cT0urpgNXy4aM6eSeCknaVDNne8y45Uz74Fb15yyXUzOStlceOJBan7A==", "license": "MIT", "dependencies": { "fzstd": "0.1.1", @@ -7194,14 +8254,11 @@ }, "node_modules/hysnappy": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hysnappy/-/hysnappy-1.0.0.tgz", - "integrity": "sha512-MNrC4NfwDGPb889O6gIfEtbvEZCSWUsSEhsz4Oq2FRcpGtXHfeVz3KciSPp5Pnnz1NjFMgDQNfxdJozymJEDDA==", "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": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7216,8 +8273,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", @@ -7236,8 +8291,6 @@ }, "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": { @@ -7246,8 +8299,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7263,8 +8314,6 @@ }, "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", "funding": { @@ -7274,8 +8323,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -7284,22 +8331,32 @@ }, "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/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": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ip-address": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -7308,22 +8365,22 @@ }, "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", "engines": { "node": ">= 0.10" } }, - "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-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": "MIT", "dependencies": { - "hasown": "^2.0.2" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -7332,4466 +8389,6296 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { + "node_modules/is-async-function": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", + "dependencies": { + "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": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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-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": { + "has-bigints": "^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-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": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "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-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" + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } }, - "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-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": ">=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==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "license": "MIT", - "peer": true - }, - "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-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": { - "@types/estree": "*" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", + "hasown": "^2.0.4" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "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": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "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": "BlueOak-1.0.0", - "optional": true, + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "call-bound": "^1.0.4" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">= 0.4" }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "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": "BSD-3-Clause", - "optional": true, + "license": "MIT", "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jose": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", - "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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": { + "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.4" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "node_modules/is-glob": { + "version": "4.0.3", "dev": true, "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, - "node_modules/js-sha256": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", - "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "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" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "node_modules/is-module": { + "version": "1.0.0", "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "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": { - "argparse": "^2.0.1" + "engines": { + "node": ">= 0.4" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "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" + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } }, - "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", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "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", - "optional": true, "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=16" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/is-plain-obj": { + "version": "4.1.0", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/is-promise": { + "version": "4.0.0", "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/is-reference": { + "version": "1.2.1", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" + "dependencies": { + "@types/estree": "*" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "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", "dependencies": { - "json-buffer": "3.0.1" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "devOptional": true, + "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": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/known-css-properties": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", - "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "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" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/kysely": { - "version": "0.27.6", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz", - "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", - "devOptional": true, + "node_modules/is-stream": { + "version": "2.0.1", + "dev": true, "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "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", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/libphonenumber-js": { - "version": "1.12.36", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.36.tgz", - "integrity": "sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==", + "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", - "optional": true + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/libxmljs2": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/libxmljs2/-/libxmljs2-0.37.0.tgz", - "integrity": "sha512-Xb78V8GZouoZFrq8cCwx7+G3WYOcJG0xb3YUbweSyE4z2EIrQCZMr3Ye/dHn4mESs6YxUMeQeUZm5IXg+iLHog==", + "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, - "hasInstallScript": true, "license": "MIT", - "optional": true, "dependencies": { - "bindings": "~1.5.0", - "nan": "~2.22.2", - "node-gyp": "^11.2.0", - "prebuild-install": "^7.1.3" + "which-typed-array": "^1.1.16" }, "engines": { - "node": ">=22" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "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": "MPL-2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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", "dependencies": { - "detect-libc": "^2.0.3" + "call-bound": "^1.0.3" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", - "cpu": [ - "arm64" - ], + "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": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", - "cpu": [ - "arm64" - ], + "node_modules/isarray": { + "version": "1.0.0", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "license": "MIT" }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", - "cpu": [ - "x64" - ], + "node_modules/isexe": { + "version": "2.0.0", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "license": "ISC" }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", - "cpu": [ - "x64" - ], + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "license": "BSD-3-Clause", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=8" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", - "cpu": [ - "arm" - ], + "node_modules/istanbul-lib-report": { + "version": "3.0.1", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=10" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", - "cpu": [ - "arm64" - ], + "node_modules/istanbul-reports": { + "version": "3.2.0", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=8" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", - "cpu": [ - "arm64" - ], + "node_modules/jackspeak": { + "version": "3.4.3", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node_modules/jiti": { + "version": "2.6.1", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", - "cpu": [ - "x64" - ], + "node_modules/joi": { + "version": "17.13.3", "dev": true, - "license": "MPL-2.0", + "license": "BSD-3-Clause", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/jose": { + "version": "6.2.1", + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/panva" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", - "cpu": [ - "arm64" - ], + "node_modules/joycon": { + "version": "3.1.1", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=10" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", - "cpu": [ - "x64" - ], + "node_modules/js-sha256": { + "version": "0.11.1", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "node_modules/json-buffer": { + "version": "3.0.1", + "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", - "engines": { - "node": ">=10" + "dependencies": { + "chalk": "^2.4.1", + "lodash.get": "^4.4.2" } }, - "node_modules/local-pkg": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", - "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "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": { - "mlly": "^1.7.4", - "pkg-types": "^2.3.0", - "quansync": "^0.2.11" + "color-convert": "^1.9.0" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "node": ">=4" } }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "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": { - "p-locate": "^5.0.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "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", - "peer": true + "dependencies": { + "color-name": "1.1.3" + } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "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/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0", - "peer": true - }, - "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==", + "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": "ISC", - "optional": true - }, - "node_modules/lru.min": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", - "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", "license": "MIT", - "peer": true, "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" + "node": ">=0.8.0" } }, - "node_modules/luxon": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", - "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "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": ">=12" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" + "node": ">=4" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "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": { - "semver": "^7.5.3" + "has-flag": "^3.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "node_modules/json-schema-to-ts": { + "version": "3.1.1", "dev": true, - "license": "ISC", + "license": "MIT", "optional": true, "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=16" } }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, "license": "MIT", "bin": { - "marked": "bin/marked.js" + "json5": "lib/cli.js" }, "engines": { - "node": ">= 18" + "node": ">=6" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/keyv": { + "version": "4.5.4", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/mdn-data": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.23.0.tgz", - "integrity": "sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, + "node_modules/kleur": { + "version": "4.1.5", + "devOptional": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=6" } }, - "node_modules/memoize-weak": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", - "integrity": "sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==", + "node_modules/known-css-properties": { + "version": "0.37.0", "dev": true, - "license": "ISC" - }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, + "node_modules/kysely": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.2.tgz", + "integrity": "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==", "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=22.0.0" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/lazystream": { + "version": "1.0.1", "dev": true, "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.6.3" } }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", "dev": true, "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "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/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "safe-buffer": "~5.1.0" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/levn": { + "version": "0.4.1", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": "*" + "node": ">= 0.8.0" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/libphonenumber-js": { + "version": "1.12.36", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "optional": true }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "node_modules/libxmljs2": { + "version": "0.37.0", "dev": true, - "license": "BlueOak-1.0.0", + "hasInstallScript": true, + "license": "MIT", "optional": true, + "dependencies": { + "bindings": "~1.5.0", + "nan": "~2.22.2", + "node-gyp": "^11.2.0", + "prebuild-install": "^7.1.3" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=22" } }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "optional": true, + "node_modules/lightningcss": { + "version": "1.31.1", + "devOptional": true, + "license": "MPL-2.0", "dependencies": { - "minipass": "^7.0.3" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" } }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", "optional": true, - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, + "os": [ + "android" + ], "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">= 12.0.0" }, - "optionalDependencies": { - "encoding": "^0.1.13" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, + "os": [ + "darwin" + ], "engines": { - "node": ">= 8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", "optional": true, - "dependencies": { - "minipass": "^7.1.2" + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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 - }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/mlly/node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/mlly/node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "node_modules/lilconfig": { + "version": "2.1.0", "dev": true, "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "engines": { + "node": ">=10" } }, - "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "node_modules/local-pkg": { + "version": "1.2.1", + "dev": true, "license": "MIT", "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - } - }, - "node_modules/mongodb": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", - "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.1.1", - "mongodb-connection-string-url": "^7.0.0" + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" + "node": ">=14" }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", - "license": "Apache-2.0", - "peer": true, + "node_modules/locate-character": { + "version": "3.0.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=20.19.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/moo": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", - "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "node_modules/lodash": { + "version": "4.17.21", "dev": true, - "license": "BSD-3-Clause", - "optional": true + "license": "MIT" }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/luxon": { + "version": "3.5.0", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=12" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "node_modules/magic-string": { + "version": "0.30.21", "devOptional": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/magicast": { + "version": "0.5.3", "dev": true, - "license": "MIT" - }, - "node_modules/mysql2": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", - "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "license": "MIT", - "peer": true, "dependencies": { - "aws-ssl-profiles": "^1.1.1", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" - }, - "engines": { - "node": ">= 8.0" + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "node_modules/make-dir": { + "version": "4.0.0", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "lru.min": "^1.1.0" + "semver": "^7.5.3" }, "engines": { - "node": ">=8.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/nan": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", - "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "node_modules/make-fetch-happen": { + "version": "14.0.3", "dev": true, - "license": "MIT", - "optional": true + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/marked": { + "version": "14.0.0", "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "marked": "bin/marked.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">= 18" } }, - "node_modules/nanostores": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.1.tgz", - "integrity": "sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, "license": "MIT", "engines": { - "node": "^20.0.0 || >=22.0.0" + "node": ">= 0.4" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "node_modules/mdn-data": { + "version": "2.23.0", "dev": true, - "license": "MIT", - "optional": true + "license": "CC0-1.0" }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/media-typer": { + "version": "1.1.0", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "node_modules/nearley": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "node_modules/memoize-weak": { + "version": "1.0.2", "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" - } - }, - "node_modules/nearley/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "optional": true + "license": "ISC" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "node_modules/merge-descriptors": { + "version": "2.0.0", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "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", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" + "node": ">= 8" } }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "license": "MIT", - "peer": true - }, - "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "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", - "optional": true, "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=8.6" } }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "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": "BlueOak-1.0.0", - "optional": true, + "license": "MIT", "engines": { - "node": ">=18" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^3.1.1" + "node": ">=8.6" }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "node_modules/mime-db": { + "version": "1.54.0", "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, + "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">= 0.6" } }, - "node_modules/normalize-package-data": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", - "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "node_modules/mime-types": { + "version": "3.0.2", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "hosted-git-info": "^9.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" + "mime-db": "^1.54.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/normalize-url": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", - "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "node_modules/mimic-response": { + "version": "3.1.0", "dev": true, "license": "MIT", "optional": true, "engines": { - "node": ">=14.16" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/nypm": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", - "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", - "license": "MIT", - "peer": true, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", "dependencies": { - "citty": "^0.2.0", - "pathe": "^2.0.3", - "tinyexec": "^1.0.2" - }, - "bin": { - "nypm": "dist/cli.mjs" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=18" + "node": "*" } }, - "node_modules/nypm/node_modules/citty": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", - "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, "license": "MIT", - "peer": true + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/oauth2-mock-server": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/oauth2-mock-server/-/oauth2-mock-server-8.2.2.tgz", - "integrity": "sha512-ZjMFtomGM4q1DflxEJpq2WVtSLbOjqlJYkkuGWdq9nL3oAbDExamFyhDN/bUqcooCHWeI0n99K/NU+gXEZLNiA==", + "node_modules/minipass": { + "version": "7.1.3", "dev": true, - "license": "MIT", - "dependencies": { - "basic-auth": "^2.0.1", - "cors": "^2.8.6", - "express": "^5.2.1", - "is-plain-obj": "^4.1.0", - "jose": "^6.1.3" - }, - "bin": { - "oauth2-mock-server": "dist/oauth2-mock-server.js" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.19 || ^22.12 || ^24" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/minipass-collect": { + "version": "2.0.1", "dev": true, - "license": "MIT", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.3" + }, "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/minipass-fetch": { + "version": "4.0.1", "dev": true, "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, "engines": { - "node": ">= 0.4" + "node": "^18.17.0 || >=20.5.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "encoding": "^0.1.13" } }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "devOptional": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "license": "MIT", - "peer": true - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", + "node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, "engines": { - "node": ">=14.0.0" + "node": ">= 8" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", "dev": true, - "license": "MIT", + "license": "ISC", + "optional": true, "dependencies": { - "ee-first": "1.1.1" + "yallist": "^4.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", "dev": true, "license": "ISC", - "dependencies": { - "wrappy": "1" - } + "optional": true }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/minipass-pipeline": { + "version": "1.2.4", "dev": true, - "license": "MIT", + "license": "ISC", + "optional": true, "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "minipass": "^3.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=8" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", "dev": true, - "license": "MIT", + "license": "ISC", + "optional": true, "dependencies": { - "yocto-queue": "^0.1.0" + "yallist": "^4.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", "dev": true, - "license": "MIT", + "license": "ISC", + "optional": true + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "license": "ISC", + "optional": true, "dependencies": { - "p-limit": "^3.0.2" + "minipass": "^3.0.0" }, "engines": { - "node": ">=10" + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minizlib": { + "version": "3.1.0", "dev": true, "license": "MIT", "optional": true, - "engines": { - "node": ">=18" + "dependencies": { + "minipass": "^7.1.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 18" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "node_modules/mkdirp": { + "version": "3.0.1", "dev": true, - "license": "BlueOak-1.0.0", - "optional": true + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/package-manager-detector": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "node_modules/mkdirp-classic": { + "version": "0.5.3", "dev": true, "license": "MIT" }, - "node_modules/packageurl-js": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/packageurl-js/-/packageurl-js-2.0.1.tgz", - "integrity": "sha512-N5ixXjzTy4QDQH0Q9YFjqIWd6zH6936Djpl2m9QNFmDv5Fum8q8BjkpAcHNMzOFE0IwQrFhJWex3AN6kS0OSwg==", + "node_modules/mlly": { + "version": "1.8.2", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } }, - "node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", "dev": true, "license": "MIT" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", "dev": true, "license": "MIT", "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "node_modules/monaco-editor": { + "version": "0.55.1", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/mri": { + "version": "1.2.0", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=4" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, + "node_modules/mrmime": { + "version": "2.0.1", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.22.2", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "devOptional": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" + "url": "https://github.com/sponsors/ai" } ], "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": ">=14.0.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/nanostores": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", + "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "engines": { - "node": ">=8" + "node": "^20.0.0 || >=22.0.0" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "node_modules/napi-build-utils": { + "version": "2.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "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": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/napi-postinstall" } }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "node_modules/natural-compare": { + "version": "1.4.0", "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "devOptional": true, + "node_modules/nearley": { + "version": "2.20.1", + "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "optional": true, + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" } }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "dev": true, "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" + "optional": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "node_modules/node-abi": { + "version": "3.87.0", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "split2": "^4.0.0" + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" } }, - "node_modules/pino-pretty": { - "version": "13.1.3", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", - "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "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": { - "colorette": "^2.0.7", - "dateformat": "^4.6.3", - "fast-copy": "^4.0.0", - "fast-safe-stringify": "^2.1.1", - "help-me": "^5.0.0", - "joycon": "^3.1.1", - "minimist": "^1.2.6", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pump": "^3.0.0", - "secure-json-parse": "^4.0.0", - "sonic-boom": "^4.0.1", - "strip-json-comments": "^5.0.2" + "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": { - "pino-pretty": "bin.js" + "semver": "bin/semver.js" } }, - "node_modules/pino-pretty/node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "node_modules/node-gyp": { + "version": "11.5.0", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.16" + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.5", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } }, - "node_modules/pixelmatch": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", - "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "node_modules/node-gyp/node_modules/which": { + "version": "5.0.0", "dev": true, "license": "ISC", + "optional": true, "dependencies": { - "pngjs": "^7.0.0" + "isexe": "^3.1.1" }, "bin": { - "pixelmatch": "bin/pixelmatch" + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "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": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" + "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/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "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": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright-core": "1.58.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/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", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "^3.0.0" }, "bin": { - "playwright": "cli.js" + "nopt": "bin/nopt.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "node_modules/normalize-package-data": { + "version": "8.0.0", "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "node_modules/normalize-path": { + "version": "3.0.0", "dev": true, "license": "MIT", "engines": { - "node": ">=14.19.0" + "node": ">=0.10.0" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "devOptional": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/normalize-url": { + "version": "8.1.1", + "dev": true, "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, + "optional": true, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "node_modules/oauth2-mock-server": { + "version": "8.2.2", "dev": true, "license": "MIT", "dependencies": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "basic-auth": "^2.0.1", + "cors": "^2.8.6", + "express": "^5.2.1", + "is-plain-obj": "^4.1.0", + "jose": "^6.1.3" }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" + "bin": { + "oauth2-mock-server": "dist/oauth2-mock-server.js" }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } + "engines": { + "node": "^20.19 || ^22.12 || ^24" } }, - "node_modules/postcss-load-config/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "node_modules/object-assign": { + "version": "4.1.1", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, - "node_modules/postcss-safe-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "node_modules/object-inspect": { + "version": "1.13.4", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "engines": { - "node": ">=18.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.4.31" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-scss": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "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, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-scss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.4.29" + "node": ">= 0.4" } }, - "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "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": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "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": ">=4" - } - }, - "node_modules/postgres": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", - "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "license": "Unlicense", - "peer": true, - "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "type": "individual", - "url": "https://github.com/sponsors/porsager" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "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", - "optional": true, "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" }, "engines": { - "node": ">=10" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" } }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "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", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=14" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/prettier-plugin-svelte": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.0.tgz", - "integrity": "sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg==", + "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", - "peerDependencies": { - "prettier": "^3.0.0", - "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/prettier-plugin-tailwindcss": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", - "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "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", - "engines": { - "node": ">=20.19" + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, - "peerDependencies": { - "@ianvs/prettier-plugin-sort-imports": "*", - "@prettier/plugin-hermes": "*", - "@prettier/plugin-oxc": "*", - "@prettier/plugin-pug": "*", - "@shopify/prettier-plugin-liquid": "*", - "@trivago/prettier-plugin-sort-imports": "*", - "@zackad/prettier-plugin-twig": "*", - "prettier": "^3.0", - "prettier-plugin-astro": "*", - "prettier-plugin-css-order": "*", - "prettier-plugin-jsdoc": "*", - "prettier-plugin-marko": "*", - "prettier-plugin-multiline-arrays": "*", - "prettier-plugin-organize-attributes": "*", - "prettier-plugin-organize-imports": "*", - "prettier-plugin-sort-imports": "*", - "prettier-plugin-svelte": "*" + "engines": { + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "@ianvs/prettier-plugin-sort-imports": { - "optional": true - }, - "@prettier/plugin-hermes": { - "optional": true - }, - "@prettier/plugin-oxc": { - "optional": true - }, - "@prettier/plugin-pug": { - "optional": true - }, - "@shopify/prettier-plugin-liquid": { - "optional": true - }, - "@trivago/prettier-plugin-sort-imports": { - "optional": true - }, - "@zackad/prettier-plugin-twig": { - "optional": true - }, - "prettier-plugin-astro": { - "optional": true - }, - "prettier-plugin-css-order": { - "optional": true - }, - "prettier-plugin-jsdoc": { - "optional": true - }, - "prettier-plugin-marko": { - "optional": true - }, - "prettier-plugin-multiline-arrays": { - "optional": true - }, - "prettier-plugin-organize-attributes": { - "optional": true - }, - "prettier-plugin-organize-imports": { - "optional": true - }, - "prettier-plugin-sort-imports": { - "optional": true - }, - "prettier-plugin-svelte": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/pretty-bytes": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-7.1.0.tgz", - "integrity": "sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==", + "node_modules/obug": { + "version": "2.1.1", + "devOptional": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", "license": "MIT", "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=14.0.0" } }, - "node_modules/prisma": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.2.tgz", - "integrity": "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, + "node_modules/on-finished": { + "version": "2.4.1", + "dev": true, + "license": "MIT", "dependencies": { - "@prisma/config": "7.4.2", - "@prisma/dev": "0.20.0", - "@prisma/engines": "7.4.2", - "@prisma/studio-core": "0.13.1", - "mysql2": "3.15.3", - "postgres": "3.4.7" - }, - "bin": { - "prisma": "build/index.js" + "ee-first": "1.1.1" }, "engines": { - "node": "^20.19 || ^22.12 || >=24.0" - }, - "peerDependencies": { - "better-sqlite3": ">=9.0.0", - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "better-sqlite3": { - "optional": true - }, - "typescript": { - "optional": true - } + "node": ">= 0.8" } }, - "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "node_modules/once": { + "version": "1.4.0", "dev": true, "license": "ISC", - "optional": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "dependencies": { + "wrappy": "1" } }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/prom-client": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", - "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", - "license": "Apache-2.0", + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/api": "^1.4.0", - "tdigest": "^0.1.1" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": "^16 || ^18 || >=20" + "node": ">= 0.8.0" } }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "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", - "optional": true, "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", - "license": "ISC", - "peer": true - }, - "node_modules/property-expr": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", - "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "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==", + "node_modules/p-locate": { + "version": "5.0.0", "dev": true, "license": "MIT", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "p-limit": "^3.0.2" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "node_modules/p-map": { + "version": "7.0.4", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, + "license": "MIT", + "optional": true, "engines": { - "node": ">=0.6" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], "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", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "node_modules/packageurl-js": { + "version": "2.0.1", + "dev": true, "license": "MIT" }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "node_modules/pako": { + "version": "0.2.9", "dev": true, - "license": "CC0-1.0", - "optional": true + "license": "MIT" }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" + "callsites": "^3.0.0" }, "engines": { - "node": ">=0.12" + "node": ">=6" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/parseurl": { + "version": "1.3.3", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "node_modules/path-exists": { + "version": "4.0.0", "dev": true, "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, "engines": { - "node": ">= 0.10" + "node": ">=8" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" } }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "node_modules/path-key": { + "version": "3.1.1", "dev": true, "license": "MIT", - "optional": true, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "license": "MIT", - "peer": true, - "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" - } + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", - "peer": true, + "node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "node_modules/path-to-regexp": { + "version": "8.3.0", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/pathe": { + "version": "2.0.3", + "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.21.0", "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" }, "engines": { - "node": ">= 6" + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "node_modules/pg-cloudflare": { + "version": "1.4.0", "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "license": "ISC", "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "node": ">=4.0.0" } }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "node_modules/pg-pool": { + "version": "3.14.0", "license": "MIT", - "engines": { - "node": ">= 12.13.0" + "peerDependencies": { + "pg": ">=8.0" } }, - "node_modules/regexp-to-ast": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", - "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", - "license": "MIT", - "peer": true + "node_modules/pg-protocol": { + "version": "1.14.0", + "license": "MIT" }, - "node_modules/regexp-tree": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", - "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", - "dev": true, + "node_modules/pg-types": { + "version": "2.2.0", "license": "MIT", - "bin": { - "regexp-tree": "bin/regexp-tree" + "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/remeda": { - "version": "2.33.4", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", - "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "node_modules/pgpass": { + "version": "1.0.5", "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/remeda" + "dependencies": { + "split2": "^4.1.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", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "node_modules/picocolors": { + "version": "1.1.1", + "devOptional": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, + "node_modules/pino": { + "version": "10.3.1", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bin": { + "pino": "bin.js" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "split2": "^4.0.0" } }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "node_modules/pino-pretty": { + "version": "13.1.3", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.12" + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "license": "MIT" + }, + "node_modules/pixelmatch": { + "version": "7.1.0", "devOptional": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@types/estree": "1.0.8" + "pngjs": "^7.0.0" }, "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" + "pixelmatch": "bin/pixelmatch" } }, - "node_modules/rou3": { - "version": "0.7.12", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", - "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", - "license": "MIT" - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "node_modules/pkg-types": { + "version": "2.3.1", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" } }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "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": "MIT", + "license": " Apache-2.0", "dependencies": { - "mri": "^1.1.0" + "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" }, - "engines": { - "node": ">=6" + "bin": { + "plantuml-parser": "dist/bin/cli.js" } }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", - "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "node_modules/plantuml-parser/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", - "dependencies": { - "regexp-tree": "~0.1.1" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, - "node_modules/schemes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/schemes/-/schemes-1.4.0.tgz", - "integrity": "sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==", + "node_modules/plantuml-parser/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": "MIT", - "optional": true, + "license": "ISC", "dependencies": { - "extend": "^3.0.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" } }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "node_modules/plantuml-parser/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, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/plantuml-parser/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": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "node_modules/plantuml-parser/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": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=8" } }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "peer": true - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "node_modules/plantuml-parser/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": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">= 18" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/set-cookie-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", - "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "node_modules/plantuml-parser/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": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "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": ">=8" + "node": ">=10" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", + "node_modules/plantuml-parser/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": ">=8" + "node": ">=10" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", + "node_modules/playwright": { + "version": "1.58.2", + "devOptional": true, + "license": "Apache-2.0", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "node_modules/playwright-core": { + "version": "1.58.2", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "devOptional": true, + "license": "MIT", + "engines": { + "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", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, + "node_modules/postcss": { + "version": "8.5.6", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^10 || ^12 || >=14" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/postcss-load-config": { + "version": "3.1.4", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" }, "engines": { - "node": ">= 0.4" + "node": ">= 10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.2", + "dev": true, "license": "ISC", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "node_modules/postcss-safe-parser": { + "version": "7.0.1", "dev": true, "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" + "type": "opencollective", + "url": "https://opencollective.com/postcss/" }, { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "license": "MIT", - "optional": true + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "node_modules/postcss-scss": { + "version": "4.0.9", "dev": true, "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" + "type": "opencollective", + "url": "https://opencollective.com/postcss/" }, { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "license": "MIT", - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" } }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "devOptional": true, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "dev": true, "license": "MIT", "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=18" + "node": ">=4" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "node_modules/postgres-array": { + "version": "2.0.0", "license": "MIT", - "optional": true, "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" + "node": ">=4" } }, - "node_modules/smtp-address-parser": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.1.0.tgz", - "integrity": "sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==", - "dev": true, + "node_modules/postgres-bytea": { + "version": "1.0.1", "license": "MIT", - "optional": true, - "dependencies": { - "nearley": "^2.20.1" - }, "engines": { - "node": ">=0.10" + "node": ">=0.10.0" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "node_modules/postgres-date": { + "version": "1.0.7", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", "license": "MIT", - "optional": true, "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" + "xtend": "^4.0.0" }, "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" + "node": ">=0.10.0" } }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "node_modules/prebuild-install": { + "version": "7.1.3", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" }, "engines": { - "node": ">= 14" + "node": ">=10" } }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "devOptional": true, - "license": "BSD-3-Clause", "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "memory-pager": "^1.0.2" + "node": ">= 0.8.0" } }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "node_modules/prettier": { + "version": "3.8.1", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "node_modules/prettier-plugin-svelte": { + "version": "3.5.0", "dev": true, "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", "dev": true, - "license": "CC0-1.0" - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", + "license": "MIT", "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sqlite-wasm-kysely": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz", - "integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==", - "dev": true, - "dependencies": { - "@sqlite.org/sqlite-wasm": "^3.48.0-build2" + "node": ">=20.19" }, "peerDependencies": { - "kysely": "*" + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } } }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "node_modules/pretty-bytes": { + "version": "7.1.0", "license": "MIT", - "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "node_modules/proc-log": { + "version": "5.0.0", "dev": true, "license": "ISC", "optional": true, - "dependencies": { - "minipass": "^7.0.3" - }, "engines": { "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "node_modules/process": { + "version": "0.11.10", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6.0" } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "node_modules/process-nextick-args": { + "version": "2.0.1", + "dev": true, "license": "MIT" }, - "node_modules/stream-browserify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.4", - "readable-stream": "^3.5.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/process-warning": { + "version": "5.0.0", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/fastify" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], "license": "MIT" }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/prom-client": { + "version": "15.1.3", + "license": "Apache-2.0", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^16 || ^18 || >=20" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/promise-retry": { + "version": "2.0.1", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "err-code": "^2.0.2", + "retry": "^0.12.0" }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/proper-lockfile": { + "version": "4.1.2", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", "dev": true, - "license": "MIT", - "optional": true + "license": "ISC" }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/properties-reader": { + "version": "3.0.1", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "ansi-regex": "^5.0.1" + "@kwsites/file-exists": "^1.1.1", + "mkdirp": "^3.0.1" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" } }, - "node_modules/stringtemplate4ts": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/stringtemplate4ts/-/stringtemplate4ts-1.0.9.tgz", - "integrity": "sha512-KYZm2bJlSjynG5Y+L46fkaKBQG6mhV6hb2RBA8dpx3/Vj6G4u7gwXNKYvaN9+QD5sj68/1srtSNDvqEso7MwsQ==", + "node_modules/property-expr": { + "version": "2.0.6", "dev": true, "license": "MIT", - "dependencies": { - "antlr4ng": "3.0.15", - "fast-printf": "1.6.10", - "he": "1.2.0", - "luxon": "3.5.0" - } - }, - "node_modules/stringtemplate4ts/node_modules/antlr4ng": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/antlr4ng/-/antlr4ng-3.0.15.tgz", - "integrity": "sha512-VELFqTfcpGI2bj6ScMWuxM3FI6HOsojrgmnw3cCbUtsQ1DNOq32wJsjOt7vLvfIniyyuE1DIYegGcuFmn+jgyw==", - "dev": true, - "license": "BSD-3-Clause" + "optional": true }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/protobufjs": { + "version": "7.6.3", "dev": true, - "license": "MIT", - "optional": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "ansi-regex": "^6.2.2" + "@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/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=12.0.0" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/proxy-addr": { + "version": "2.0.7", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "ansi-regex": "^5.0.1" + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" }, "engines": { - "node": ">=8" + "node": ">= 0.10" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/pump": { + "version": "3.0.3", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "node_modules/punycode": { + "version": "2.3.1", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=6" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "node_modules/pure-rand": { + "version": "6.1.0", "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], "license": "MIT", + "optional": true + }, + "node_modules/qs": { + "version": "6.15.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, "engines": { - "node": ">=8" + "node": ">=0.6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "node_modules/quansync": { + "version": "0.2.11", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "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/NaturalIntelligence" + "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/superstruct": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", - "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14.0.0" - } + "license": "CC0-1.0", + "optional": true }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/randexp": { + "version": "0.4.6", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "has-flag": "^4.0.0" + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" }, "engines": { - "node": ">=8" + "node": ">=0.12" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/range-parser": { + "version": "1.2.1", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6" } }, - "node_modules/svelte": { - "version": "5.53.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.5.tgz", - "integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==", - "devOptional": true, + "node_modules/raw-body": { + "version": "3.0.2", + "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.6.3", - "esm-env": "^1.2.1", - "esrap": "^2.2.2", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.10" } }, - "node_modules/svelte-check": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.3.tgz", - "integrity": "sha512-4HtdEv2hOoLCEsSXI+RDELk9okP/4sImWa7X02OjMFFOWeSdFF3NFy3vqpw0z+eH9C88J9vxZfUXz/Uv2A1ANw==", + "node_modules/rc": { + "version": "1.2.8", "dev": true, - "license": "MIT", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" }, "bin": { - "svelte-check": "bin/svelte-check" - }, + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "optional": true, "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" + "node": ">=0.10.0" } }, - "node_modules/svelte-eslint-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", - "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==", + "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", "license": "MIT", "dependencies": { - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.0.0", - "espree": "^10.0.0", - "postcss": "^8.4.49", - "postcss-scss": "^4.0.9", - "postcss-selector-parser": "^7.0.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0", - "pnpm": "10.24.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } + "node": ">= 6" } }, - "node_modules/svelte/node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "devOptional": true, + "node_modules/readdir-glob": { + "version": "1.1.3", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.1", + "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.6" + "balanced-match": "^1.0.0" } }, - "node_modules/sveltekit-superforms": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/sveltekit-superforms/-/sveltekit-superforms-2.30.0.tgz", - "integrity": "sha512-EzXD7sHbi7yBU/eNtzVm6P6axcrVM8BArkbiT96Vdx48s5m4KXte/tbbp3UULtEW8Nk9wt2hYkGeq7nDBwVceg==", + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ciscoheat" - }, - { - "type": "ko-fi", - "url": "https://ko-fi.com/ciscoheat" - }, - { - "type": "paypal", - "url": "https://www.paypal.com/donate/?hosted_button_id=NY7F5ALHHSVQS" - } - ], - "license": "MIT", + "license": "ISC", "dependencies": { - "devalue": "^5.6.3", - "memoize-weak": "^1.0.2", - "ts-deepmerge": "^7.0.3" - }, - "optionalDependencies": { - "@exodus/schemasafe": "^1.3.0", - "@standard-schema/spec": "^1.0.0", - "@typeschema/class-validator": "^0.3.0", - "@valibot/to-json-schema": "^1.5.0", - "@vinejs/vine": "^3.0.1", - "arktype": "^2.1.29", - "class-validator": "^0.14.3", - "effect": "^3.19.12", - "joi": "^17.13.3", - "json-schema-to-ts": "^3.1.1", - "superstruct": "^2.0.2", - "typebox": "^1.0.62", - "valibot": "^1.2.0", - "yup": "^1.7.1", - "zod": "^4.1.13", - "zod-v3-to-json-schema": "^4.0.0" - }, - "peerDependencies": { - "@exodus/schemasafe": "^1.3.0", - "@sveltejs/kit": "1.x || 2.x", - "@typeschema/class-validator": "^0.3.0", - "@vinejs/vine": "^1.8.0 || ^2.0.0 || ^3.0.0", - "arktype": ">=2.0.0-rc.23", - "class-validator": "^0.14.1", - "effect": "^3.13.7", - "joi": "^17.13.1", - "superstruct": "^2.0.2", - "svelte": "3.x || 4.x || >=5.0.0-next.51", - "typebox": "^1.0.36", - "valibot": "^1.2.0", - "yup": "^1.4.0", - "zod": "^3.25.0 || ^4.0.0" + "brace-expansion": "^2.0.1" }, - "peerDependenciesMeta": { - "@exodus/schemasafe": { - "optional": true - }, - "@typeschema/class-validator": { - "optional": true - }, - "@vinejs/vine": { - "optional": true - }, - "arktype": { - "optional": true - }, - "class-validator": { - "optional": true - }, - "effect": { - "optional": true - }, - "joi": { - "optional": true - }, - "superstruct": { - "optional": true - }, - "typebox": { - "optional": true - }, - "valibot": { - "optional": true - }, - "yup": { - "optional": true - }, - "zod": { - "optional": true - } + "engines": { + "node": ">=10" } }, - "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "node_modules/readdirp": { + "version": "4.1.2", "dev": true, "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">= 14.18.0" }, "funding": { - "url": "https://opencollective.com/synckit" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/tailwind-csstree": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/tailwind-csstree/-/tailwind-csstree-0.1.4.tgz", - "integrity": "sha512-FzD187HuFIZEyeR7Xy6sJbJll2d4SybS90satC8SKIuaNRC05CxMvdzN7BUsfDQffcnabckRM5OIcfArjsZ0mg==", + "node_modules/real-require": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "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": "Apache-2.0", + "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": ">=18.18" + "node": ">= 0.4" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "node_modules/regexp-tree": { + "version": "0.1.27", "dev": true, - "license": "MIT" + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "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": ">=6" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "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": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, + "license": "MIT", "engines": { - "node": ">=18" + "node": "*" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "node_modules/require-directory": { + "version": "2.1.1", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "engines": { + "node": ">=0.10.0" } }, - "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==", + "node_modules/require-from-string": { + "version": "2.0.2", "dev": true, "license": "MIT", "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "dev": true, + "license": "MIT", "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "node_modules/resolve-from": { + "version": "4.0.0", "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=4" } }, - "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/resolve-pkg-maps": { + "version": "1.0.0", + "devOptional": true, "license": "MIT", - "dependencies": { - "bintrees": "1.0.2" + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=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/ret": { + "version": "0.1.15", + "dev": true, "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - }, + "optional": true, "engines": { - "node": ">=20" + "node": ">=0.12" } }, - "node_modules/tiny-case": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", - "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "node_modules/retry": { + "version": "0.12.0", "dev": true, "license": "MIT", - "optional": true + "engines": { + "node": ">= 4" + } }, - "node_modules/tiny-inflate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", - "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "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" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "license": "MIT", "engines": { - "node": ">=18" + "iojs": ">=1.0.0", + "node": ">=0.10.0" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/rollup": { + "version": "4.57.1", "devOptional": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" } }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/router": { + "version": "2.2.0", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, "engines": { - "node": ">=0.6" + "node": ">= 18" } }, - "node_modules/toposort": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "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", - "optional": true + "dependencies": { + "queue-microtask": "^1.2.2" + } }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "devOptional": true, + "node_modules/sade": { + "version": "1.8.1", + "dev": true, "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, "engines": { "node": ">=6" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "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", - "peer": true, "dependencies": { - "punycode": "^2.3.1" + "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": ">=18" + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "node_modules/safe-array-concat/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", - "optional": true + "license": "MIT" }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "node_modules/safe-buffer": { + "version": "5.1.2", + "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": ">=18.12" + "node": ">= 0.4" }, - "peerDependencies": { - "typescript": ">=4.8.4" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ts-deepmerge": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-7.0.3.tgz", - "integrity": "sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==", + "node_modules/safe-push-apply/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": "ISC", - "engines": { - "node": ">=14.13.1" - } + "license": "MIT" }, - "node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "node_modules/safe-regex": { + "version": "2.1.1", "dev": true, "license": "MIT", "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" + "regexp-tree": "~0.1.1" } }, - "node_modules/tsconfig-paths-webpack-plugin": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", - "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "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": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.7.0", - "tapable": "^2.2.1", - "tsconfig-paths": "^4.1.2" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", - "dev": true, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "node": ">=10" } }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/schemes": { + "version": "1.4.0", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, - "os": [ - "darwin" + "dependencies": { + "extend": "^3.0.0" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=10" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "node_modules/send": { + "version": "1.2.1", "dev": true, - "license": "Apache-2.0", - "optional": true, + "license": "MIT", "dependencies": { - "safe-buffer": "^5.0.1" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": "*" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "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": { - "prelude-ls": "^1.2.1" + "type-fest": "^0.13.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "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)", - "optional": true, "engines": { - "node": ">=12.20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "node_modules/serve-static": { + "version": "2.2.1", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/typebox": { - "version": "1.0.81", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.0.81.tgz", - "integrity": "sha512-bCslZUmZESHhBn4kHDghzH2oo3qu8m2W89xDLxQHv/aPvY4i81Nd1jvijlBp9wSpsVytDSfSoosbiBAjgsNb2Q==", + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "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", - "optional": true - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "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": ">=14.17" + "node": ">= 0.4" } }, - "node_modules/typescript-eslint": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", - "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "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": { - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1" + "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": "^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.0.0" + "node": ">= 0.4" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "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" - }, - "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=20.18.1" + "node": ">= 0.4" } }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicode-properties": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", - "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "node_modules/setprototypeof": { + "version": "1.2.0", "dev": true, - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.0", - "unicode-trie": "^2.0.0" - } + "license": "ISC" }, - "node_modules/unicode-trie": { + "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", - "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", "dev": true, "license": "MIT", "dependencies": { - "pako": "^0.2.5", - "tiny-inflate": "^1.0.0" + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "node_modules/shebang-regex": { + "version": "3.0.0", "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "unique-slug": "^5.0.0" - }, + "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=8" } }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "node_modules/side-channel": { + "version": "1.1.0", "dev": true, - "license": "ISC", - "optional": true, + "license": "MIT", "dependencies": { - "imurmurhash": "^0.1.4" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/unpipe": { + "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "node_modules/side-channel-map": { + "version": "1.0.1", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=18.12.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/unplugin-icons": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-23.0.1.tgz", - "integrity": "sha512-rv0XEJepajKzDLvRUWASM8K+8+/CCfZn2jtogXqg6RIp7kpatRc/aFrVJn8ANQA09e++lPEEv9yX8cC9enc+QQ==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", "dev": true, "license": "MIT", "dependencies": { - "@antfu/install-pkg": "^1.1.0", - "@iconify/utils": "^3.1.0", - "local-pkg": "^1.1.2", - "obug": "^2.1.1", - "unplugin": "^2.3.11" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, - "peerDependencies": { - "@svgr/core": ">=7.0.0", - "@svgx/core": "^1.0.1", - "@vue/compiler-sfc": "^3.0.2", - "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" + "engines": { + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "@svgr/core": { - "optional": true - }, - "@svgx/core": { - "optional": true - }, - "@vue/compiler-sfc": { - "optional": true - }, - "svelte": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/siginfo": { + "version": "2.0.0", + "devOptional": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/urlpattern-polyfill": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", - "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "node_modules/simple-concat": { + "version": "1.0.1", "dev": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" + "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", + "optional": true }, - "node_modules/uuid": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz", - "integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==", + "node_modules/simple-get": { + "version": "4.0.1", "dev": true, "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + { + "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", - "bin": { - "uuid": "dist-node/bin/uuid" + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" } }, - "node_modules/valibot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", - "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "node_modules/sirv": { + "version": "3.0.2", + "devOptional": true, "license": "MIT", - "peerDependencies": { - "typescript": ">=5" + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": ">=18" } }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "node_modules/smart-buffer": { + "version": "4.2.0", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smtp-address-parser": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" + "nearley": "^2.20.1" + }, + "engines": { + "node": ">=0.10" } }, - "node_modules/validator": { - "version": "13.15.26", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", - "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "node_modules/socks": { + "version": "2.8.7", "dev": true, "license": "MIT", "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, "engines": { - "node": ">= 0.10" + "node": ">= 10.0.0", + "npm": ">= 3.0.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "node_modules/socks-proxy-agent": { + "version": "8.0.5", "dev": true, "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, "engines": { - "node": ">= 0.8" + "node": ">= 14" } }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "node_modules/sonic-boom": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", "devOptional": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split-ca": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlite-wasm-kysely": { + "version": "0.3.0", + "dev": true, + "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.48.0-build2" }, - "bin": { - "vite": "bin/vite.js" + "peerDependencies": { + "kysely": "*" + } + }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" + "node": ">=10.16.0" }, "optionalDependencies": { - "fsevents": "~2.3.3" + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2/node_modules/nan": { + "version": "2.27.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ssri": { + "version": "12.0.0", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.3" }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "engines": { + "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", + "devOptional": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "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" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "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/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.27.0", + "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", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "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/string-width": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "antlr4ng": "3.0.15", + "fast-printf": "1.6.10", + "he": "1.2.0", + "luxon": "3.5.0" + } + }, + "node_modules/stringtemplate4ts/node_modules/antlr4ng": { + "version": "3.0.15", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/superstruct": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.53.5", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.3", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.24.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/sveltekit-superforms": { + "version": "2.30.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ciscoheat" + }, + { + "type": "ko-fi", + "url": "https://ko-fi.com/ciscoheat" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=NY7F5ALHHSVQS" + } + ], + "license": "MIT", + "dependencies": { + "devalue": "^5.6.3", + "memoize-weak": "^1.0.2", + "ts-deepmerge": "^7.0.3" + }, + "optionalDependencies": { + "@exodus/schemasafe": "^1.3.0", + "@standard-schema/spec": "^1.0.0", + "@typeschema/class-validator": "^0.3.0", + "@valibot/to-json-schema": "^1.5.0", + "@vinejs/vine": "^3.0.1", + "arktype": "^2.1.29", + "class-validator": "^0.14.3", + "effect": "^3.19.12", + "joi": "^17.13.3", + "json-schema-to-ts": "^3.1.1", + "superstruct": "^2.0.2", + "typebox": "^1.0.62", + "valibot": "^1.2.0", + "yup": "^1.7.1", + "zod": "^4.1.13", + "zod-v3-to-json-schema": "^4.0.0" + }, + "peerDependencies": { + "@exodus/schemasafe": "^1.3.0", + "@sveltejs/kit": "1.x || 2.x", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^1.8.0 || ^2.0.0 || ^3.0.0", + "arktype": ">=2.0.0-rc.23", + "class-validator": "^0.14.1", + "effect": "^3.13.7", + "joi": "^17.13.1", + "superstruct": "^2.0.2", + "svelte": "3.x || 4.x || >=5.0.0-next.51", + "typebox": "^1.0.36", + "valibot": "^1.2.0", + "yup": "^1.4.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@exodus/schemasafe": { + "optional": true + }, + "@typeschema/class-validator": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "arktype": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "effect": { + "optional": true + }, + "joi": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwind-csstree": { + "version": "0.1.4", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.11", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "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", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "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", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tdigest": { + "version": "0.1.2", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/testcontainers": { + "version": "12.0.1", + "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.0", + "get-port": "^7.2.0", + "proper-lockfile": "^4.1.2", + "properties-reader": "^3.0.1", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.1.2", + "tmp": "^0.2.6", + "undici": "^7.25.0" + } + }, + "node_modules/testcontainers/node_modules/tar-fs": { + "version": "3.1.2", + "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/text-decoder": { + "version": "1.2.7", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "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/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/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": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tiny-case": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toposort": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/totalist": { + "version": "3.0.1", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-deepmerge": { + "version": "7.0.3", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "dev": true, + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typebox": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.19.tgz", + "integrity": "sha512-9stLVZhos9aUXJzu0Yf717xbcv3vFsE5APzsQ7j/honIUFtern451IdTlqmVzPzuGxHkeWwWc2cerw6m2lqKOQ==", + "dev": true, + "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", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "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.0.0" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "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.27.2", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-icons": { + "version": "23.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/utils": "^3.1.0", + "local-pkg": "^1.1.2", + "obug": "^2.1.1", + "unplugin": "^2.3.11" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@svgr/core": ">=7.0.0", + "@svgx/core": "^1.0.1", + "@vue/compiler-sfc": "^3.0.2", + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@svgr/core": { + "optional": true + }, + "@svgx/core": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "svelte": { + "optional": true + } + } + }, + "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", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "13.0.2", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/valibot": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validator": { + "version": "13.15.26", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" ], "engines": { "node": ">=18" } }, "node_modules/vite/node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.7", "devOptional": true, "hasInstallScript": true, "license": "MIT", @@ -11802,39 +14689,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -11847,8 +14733,6 @@ }, "node_modules/vitefu": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", "devOptional": true, "license": "MIT", "workspaces": [ @@ -11867,8 +14751,6 @@ }, "node_modules/vitest": { "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -11945,8 +14827,6 @@ }, "node_modules/vitest-browser-svelte": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-2.0.2.tgz", - "integrity": "sha512-OLJVYoIYflwToFIy3s41pZ9mVp6dwXfYd8IIsWoc57g8DyN3SxsNJ5GB1xWFPxLFlKM+1MPExjPxLaqdELrfRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11960,41 +14840,14 @@ "vitest": "^4.0.0" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", - "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "dev": true, "license": "MIT" }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "peer": true, - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -12006,10 +14859,104 @@ "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-builtin-type/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/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.23", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.23.tgz", + "integrity": "sha512-JMh8aK+1B/0bk/YNupICmH5MgCq6yNLKYQUfsZtSDLLCCmxUkHjUY+oOlIa90lO7lHN1woAfpenLdlKpXy9o+A==", + "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", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -12025,8 +14972,6 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -12035,11 +14980,8 @@ }, "node_modules/wrap-ansi": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -12055,11 +14997,8 @@ "node_modules/wrap-ansi-cjs": { "name": "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, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -12074,30 +15013,21 @@ }, "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/wrap-ansi-cjs/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, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -12109,11 +15039,8 @@ }, "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -12123,11 +15050,8 @@ }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=12" }, @@ -12137,16 +15061,12 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, "node_modules/ws": { "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -12166,8 +15086,6 @@ }, "node_modules/xml-naming": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", "funding": [ { "type": "github", @@ -12181,8 +15099,6 @@ }, "node_modules/xmlbuilder2": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", - "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", "dev": true, "license": "MIT", "dependencies": { @@ -12195,10 +15111,23 @@ "node": ">=20.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "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", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, "license": "BlueOak-1.0.0", "optional": true, @@ -12206,10 +15135,84 @@ "node": ">=18" } }, + "node_modules/yaml": { + "version": "2.9.0", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "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", + "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", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { @@ -12221,8 +15224,6 @@ }, "node_modules/yup": { "version": "1.7.1", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", - "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "dev": true, "license": "MIT", "optional": true, @@ -12233,28 +15234,64 @@ "type-fest": "^2.19.0" } }, - "node_modules/zeptomatch": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", - "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "license": "MIT", - "peer": true, - "dependencies": { - "grammex": "^3.1.11", - "graphmatch": "^1.1.0" - } - }, "node_modules/zimmerframe": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "devOptional": true, "license": "MIT" }, + "node_modules/zip-stream": { + "version": "6.0.1", + "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/buffer": { + "version": "6.0.3", + "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/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "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.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -12262,39 +15299,12 @@ }, "node_modules/zod-v3-to-json-schema": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/zod-v3-to-json-schema/-/zod-v3-to-json-schema-4.0.0.tgz", - "integrity": "sha512-KixLrhX/uPmRFnDgsZrzrk4x5SSJA+PmaE5adbfID9+3KPJcdxqRobaHU397EfWBqfQircrjKqvEqZ/mW5QH6w==", "dev": true, "license": "ISC", "optional": true, "peerDependencies": { "zod": "^3.25 || ^4.0.14" } - }, - "trino-js-client": { - "name": "trino-client", - "version": "0.2.9", - "extraneous": true, - "license": "Apache-2.0", - "dependencies": { - "axios": "1.13.2" - }, - "devDependencies": { - "@eslint/js": "9.25.1", - "@types/eslint__js": "^8.42.3", - "@types/jest": "^30.0.0", - "@types/node": "^24.2.0", - "eslint": "9.39.1", - "eslint-plugin-jest": "^29.0.1", - "jest": "^30.0.5", - "jiti": "^2.4.0", - "prettier": "^3.0.0", - "ts-jest": "^29.2.5", - "ts-node": "^10.9.2", - "typedoc": "^0.28.3", - "typescript": "^5.6.3", - "typescript-eslint": "^8.14.0" - } } } } diff --git a/package.json b/package.json index 2289b662..d1d28861 100644 --- a/package.json +++ b/package.json @@ -12,34 +12,52 @@ "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:e2e": "node --env-file=.env.test node_modules/.bin/vite build && playwright test", - "test:e2e:garage": "./e2e/run-garage-tests.sh", + "test:arch": "vitest run --config vitest.arch.config.ts", + "test:arch:report": "ARCH_REPORT=1 vitest run --config vitest.arch.config.ts", + "test:e2e": "node --env-file=.env.test node_modules/.bin/vite build && PREVIEW_PORT=$PREVIEW_PORT npx tsx e2e/support/run-e2e.ts", "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": "^4.2.1", "@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.58.2", "@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.0.18", "@vitest/coverage-v8": "^4.0.18", "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", @@ -52,6 +70,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", @@ -64,17 +83,28 @@ "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.5.17", "antlr4-c3": "^3.4.4", "antlr4ng": "^3.0.16", - "better-auth": "^1.5.4", + "better-auth": "^1.6.20", + "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", + "tar-stream": "^3.2.0", "undici": "^7.24.4" + }, + "overrides": { + "sveltekit-superforms": { + "typebox": "1.2.19" + } } } diff --git a/playwright.config.ts b/playwright.config.ts index 568b4946..bde10d01 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,13 +2,17 @@ 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({ + // Tests share mock-service state, so parallel workers can race even outside CI. + workers: 1, 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,32 +20,42 @@ export default defineConfig({ use: { baseURL, trace: 'retain-on-failure', - video: 'retain-on-failure' + video: 'retain-on-failure', + screenshot: 'only-on-failure' }, webServer: [ { command: 'npx tsx e2e/support/start-mock-oidc.ts', url: 'http://localhost:9090/.well-known/openid-configuration', - reuseExistingServer: true + reuseExistingServer: false }, { command: 'npx tsx e2e/support/start-mock-trino.ts', url: 'http://localhost:8080', - reuseExistingServer: true + reuseExistingServer: false }, { - command: 'PORT=4173 node --env-file=.env.test build', + command: 'node --env-file=.env.test build/index.js', + env: { PORT: appPort }, url: baseURL, - reuseExistingServer: true + reuseExistingServer: false } ], projects: [ + // Runs database migrations against the PostgreSQL container started by globalSetup. + // All browser projects depend on this so tests never run on an unmigrated DB. + // Container teardown is handled by the function returned from globalSetup. + { + name: 'setup-db', + testMatch: /db-migrations\.setup\.ts/ + }, // 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/, + dependencies: ['setup-db'], use: { browserName: 'chromium', viewport: { width: 1280, height: 720 } @@ -50,23 +64,12 @@ export default defineConfig({ { name: 'setup-firefox', testMatch: /auth\.setup\.ts/, + dependencies: ['setup-db'], 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: { @@ -85,24 +88,6 @@ export default defineConfig({ ...(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..72440a9e 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,12 @@ progress.progress::-moz-progress-bar { background-color: color-mix(in oklch, currentColor 50%, transparent); } } + +@keyframes nav-progress-slide { + 0% { + transform: translateX(-110%); + } + 100% { + transform: translateX(350%); + } +} diff --git a/src/app.d.ts b/src/app.d.ts index f31b166a..f2bcc032 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -16,13 +16,16 @@ 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 {} 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/circular-dependencies.spec.ts b/src/architecture/circular-dependencies.spec.ts new file mode 100644 index 00000000..b1b2ae15 --- /dev/null +++ b/src/architecture/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/helpers.ts b/src/architecture/helpers.ts new file mode 100644 index 00000000..3024b2db --- /dev/null +++ b/src/architecture/helpers.ts @@ -0,0 +1,68 @@ +/** + * Shared helpers for architecture fitness tests. + * + * ArchUnitTS only scans TypeScript (.ts) source files. Svelte (.svelte) and + * JSON files are invisible to its file graph. Rules that need to inspect + * .svelte file content therefore use plain Node.js fs helpers instead. + */ + +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/i18n-compliance.spec.ts b/src/architecture/i18n-compliance.spec.ts new file mode 100644 index 00000000..1a003f3a --- /dev/null +++ b/src/architecture/i18n-compliance.spec.ts @@ -0,0 +1,45 @@ +/** + * 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). + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { findFiles, readTextFile } from './helpers'; + +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('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/report-generation.spec.ts b/src/architecture/report-generation.spec.ts new file mode 100644 index 00000000..66ced76e --- /dev/null +++ b/src/architecture/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/ui-pattern-enforcement.spec.ts b/src/architecture/ui-pattern-enforcement.spec.ts new file mode 100644 index 00000000..4adfae29 --- /dev/null +++ b/src/architecture/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 - - - {#if showLast} +
+ +
+
+ +
+
+
+ {#if showLast} +
+ +
{/if} {/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 4ce670a5..d41d0c31 100644 --- a/src/lib/components/catalog/CatalogBrowser.svelte +++ b/src/lib/components/catalog/CatalogBrowser.svelte @@ -196,7 +196,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 = []; }); } @@ -260,27 +260,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..94878c7d --- /dev/null +++ b/src/lib/components/editor/TextEditor.svelte @@ -0,0 +1,307 @@ + + +
+ {#if !browser || !ready} +
{displayValue}
+ {/if} +
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 @@ } - + +
storage.toggleSelect(file.key, e.ctrlKey || e.metaKey)} - ondblclick={() => storage.executeAction('preview')} + ondblclick={() => { + if (isArchive) { + void storage.archive.enterArchive(file.key); + } else { + storage.executeAction('preview'); + } + }} oncontextmenu={(e) => storage.openContextMenu(e, file.key)} > diff --git a/src/lib/components/storage/explorer/FolderRow.svelte b/src/lib/components/storage/explorer/FolderRow.svelte index 5ec0645c..e427cb29 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, + parseStorageDropKeys, + canStorageDrop + } from '$lib/storage/drag-handlers.js'; + import * as m from '$lib/paraglide/messages.js'; interface Props { folder: StorageObject; @@ -15,26 +22,73 @@ 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 keys = parseStorageDropKeys(e); + if (!keys) return; + // Don't drop onto a selected folder + if (keys.includes(folder.key)) return; + // Move items into this folder + void storage.performMove(folder.key, keys); + } { if (storage.selectionMode || e.ctrlKey || e.metaKey) { storage.toggleSelect(folder.key, true); + } else if (storage.archive.isInArchive) { + storage.archive.navigateInArchive(folder.key); } else { storage.navigate(folder.key); } }} ondblclick={(e) => { if (e.ctrlKey || e.metaKey) { - storage.navigate(folder.key); + if (storage.archive.isInArchive) { + storage.archive.navigateInArchive(folder.key); + } else { + storage.navigate(folder.key); + } } }} oncontextmenu={(e) => storage.openContextMenu(e, folder.key)} @@ -47,25 +101,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..d3985a6b 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 { parseStorageDropKeys, 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 keys = parseStorageDropKeys(e); + if (!keys) return; + void storage.performMove(storage.prefix, keys); + } + + 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 keys = parseStorageDropKeys(e); + if (!keys) return; + void storage.performMove(parentPrefix(), keys); + } + + 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 keys = parseStorageDropKeys(e); + if (!keys) return; + void storage.performMove(storage.prefix, keys); + } -
+
@@ -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)} - +
+ +
-
- +
+ +
- + @@ -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,54 @@
- +
+
diff --git a/src/lib/components/storage/explorer/OperationsButton.svelte b/src/lib/components/storage/explorer/OperationsButton.svelte new file mode 100644 index 00000000..4056c44b --- /dev/null +++ b/src/lib/components/storage/explorer/OperationsButton.svelte @@ -0,0 +1,504 @@ + + + e.key === 'Escape' && (dropdownOpen = false)} /> + +{#if storage.operations.length > 0} + {#if dropdownOpen} +
(dropdownOpen = false)} + role="presentation" + aria-hidden="true" + >
+ {/if} +
+ +
+ +
+ + + {#if dropdownOpen} + + {/if} +
+{/if} diff --git a/src/lib/components/storage/explorer/SelectionToolbar.svelte b/src/lib/components/storage/explorer/SelectionToolbar.svelte index 91dd742f..693598e3 100644 --- a/src/lib/components/storage/explorer/SelectionToolbar.svelte +++ b/src/lib/components/storage/explorer/SelectionToolbar.svelte @@ -1,6 +1,7 @@ -{#if breadcrumbCtx} - {@const breadcrumbIsPinned = storage.bookmarks.isPinned( - breadcrumbCtx.bucket, - breadcrumbCtx.prefix - )} - {@const BreadcrumbPinIcon = breadcrumbIsPinned ? IconPushPin : IconPushPinOutline} - -
e.key === 'Escape' && closeBreadcrumbCtx()} - >
- -{/if} + {#snippet pinButton(bucket: string, prefix: string)} {@const pinned = storage.bookmarks.isPinned(bucket, prefix)} @@ -102,58 +260,68 @@ motion-safe:transition-[width] motion-safe:duration-150 motion-safe:group-hover:delay-700 " > - + + {/snippet}
@@ -288,61 +575,108 @@ {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..5cbde498 --- /dev/null +++ b/src/lib/components/storage/explorer/TabBar.svelte @@ -0,0 +1,425 @@ + + + + +{#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..6f823e49 100644 --- a/src/lib/components/storage/explorer/__tests__/ContextMenu.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/ContextMenu.svelte.spec.ts @@ -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..11417416 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__/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..d0d07a27 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 () => { @@ -124,13 +124,62 @@ describe('FileRow', () => { expect(spy).toHaveBeenCalledWith('click.txt', 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'); }); 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..c557470a 100644 --- a/src/lib/components/storage/explorer/__tests__/StorageBreadcrumb.svelte.spec.ts +++ b/src/lib/components/storage/explorer/__tests__/StorageBreadcrumb.svelte.spec.ts @@ -191,16 +191,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 +206,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 +228,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 +329,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 +346,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 +370,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 +433,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 +199,21 @@ {:else}
- + - {locationPath(loc)} @@ -159,14 +222,13 @@
- + -
diff --git a/src/lib/components/storage/landing/StorageConnectForm.svelte b/src/lib/components/storage/landing/StorageConnectForm.svelte index c93c963b..19ae9a26 100644 --- a/src/lib/components/storage/landing/StorageConnectForm.svelte +++ b/src/lib/components/storage/landing/StorageConnectForm.svelte @@ -1,30 +1,24 @@ -{#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} +

{$message}

+ {/if} - {#if $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 3655e81f..c43718b4 100644 --- a/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts +++ b/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts @@ -3,41 +3,16 @@ 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/server/storage/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) + storageAutoConnectEnabled: false, + storageAutoConnectTimeoutMs: 15_000 })); // Mock paraglide messages @@ -66,11 +41,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 +91,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..3c464196 --- /dev/null +++ b/src/lib/components/storage/modals/CreateModal.svelte @@ -0,0 +1,114 @@ + + + + + diff --git a/src/lib/components/storage/DeleteConnectionModal.svelte b/src/lib/components/storage/modals/DeleteConnectionModal.svelte similarity index 74% rename from src/lib/components/storage/DeleteConnectionModal.svelte rename to src/lib/components/storage/modals/DeleteConnectionModal.svelte index 39d33390..5829c280 100644 --- a/src/lib/components/storage/DeleteConnectionModal.svelte +++ b/src/lib/components/storage/modals/DeleteConnectionModal.svelte @@ -2,8 +2,9 @@ import IconContentCopy from 'virtual:icons/material-symbols/content-copy'; import IconCheck from 'virtual:icons/material-symbols/check'; import Modal from '$lib/components/Modal.svelte'; + import TooltipTrigger from '$lib/components/TooltipTrigger.svelte'; import * as m from '$lib/paraglide/messages.js'; - import type { SavedConnection } from '$lib/storage/connection-storage.js'; + import type { SavedConnection } from '$lib/storage/connection-id-header.js'; interface Props { open: boolean; @@ -17,8 +18,7 @@ let copiedField: string | null = $state(null); function connectionLabel(conn: SavedConnection): string { - if (conn.name) return conn.name; - return conn.port ? `${conn.host}:${conn.port}` : conn.host; + return conn.name || conn.host || 'S3'; } function copyField(value: string, field: string) { @@ -33,7 +33,6 @@ const host = conn.port ? `${conn.host}:${conn.port}` : conn.host; const lines = [ `Host: ${host}`, - `Type: ${m.storage_connect_type_s3()}`, ...(conn.credentials?.accessKey ? [`Access key: ${conn.credentials.accessKey}`] : []), `Region: ${conn.region.name}` ]; @@ -55,20 +54,25 @@ {#snippet copyBtn(value: string, field: string)} - + + {/snippet} @@ -104,15 +108,17 @@ {/if} - - - - + {#if connection.region?.name} + + + + + {/if}
{m.storage_connect_host()}
{m.storage_connect_region()} -
- {connection.region.name} - {@render copyBtn(connection.region.name, 'region')} -
-
{m.storage_connect_region()} +
+ {connection.region.name} + {@render copyBtn(connection.region.name, 'region')} +
+
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..9f188fcc --- /dev/null +++ b/src/lib/components/storage/modals/MoveConfirmModal.svelte @@ -0,0 +1,95 @@ + + + + + diff --git a/src/lib/components/storage/modals/PreviewModal.svelte b/src/lib/components/storage/modals/PreviewModal.svelte index adcfb244..9452a9d7 100644 --- a/src/lib/components/storage/modals/PreviewModal.svelte +++ b/src/lib/components/storage/modals/PreviewModal.svelte @@ -5,26 +5,61 @@ 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 { 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' } @@ -46,28 +81,74 @@ 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: '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); - // Revoke blob URLs when the component is destroyed + const dirty = $derived(editorText !== originalText); + + $effect(() => { + if (!dirty) return; + + function handleBeforeUnload(e: BeforeUnloadEvent) { + e.preventDefault(); + } + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }); + + const filename = $derived(objectKey ? keyToName(objectKey) : ''); + function isTooLargeToEdit(): boolean { + if (preview.kind !== 'text') return false; + return preview.totalSize > maxEditableFileSize; + } onDestroy(() => { for (const url of blobUrls) { URL.revokeObjectURL(url); @@ -81,31 +162,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 +216,18 @@ 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 }; - return; - } - - // Images — render as blob URL, or fallback if truncated if (contentType.startsWith('image/')) { if (truncated) { await res.body?.cancel(); @@ -151,7 +241,6 @@ return; } - // PDF — render in iframe if (contentType === 'application/pdf') { const blob = await res.blob(); const url = URL.createObjectURL(blob); @@ -160,27 +249,103 @@ 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 text = await readTextSafely(res, key, contentType); + if (text === null) { + preview = { kind: 'fallback', contentType, isBinary: true }; return; } - // 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,19 +359,14 @@ return; } - // All other decoded text preview = { kind: 'text', text, contentType, truncated, totalSize, previewBytes }; + 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; @@ -226,8 +386,6 @@ const buf = await res.arrayBuffer(); const bytes = new Uint8Array(buf); const truncated = res.headers.get('X-Preview-Truncated') === 'true'; - - // 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); @@ -237,7 +395,6 @@ } } - // Try strict UTF-8 (handles UTF-8 with or without BOM) try { return new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch { @@ -264,19 +421,168 @@ } } - const filename = $derived(objectKey ? keyToName(objectKey) : ''); + /** 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'); + } + } + } + } + + /** Read an NDJSON streaming response and progressively fill column data. */ + async function readNdjsonStream( + res: Response, + 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 = {}; + + 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'); + } + } + } + + return { headers: resultHeaders, rows, totalRows: resultTotalRows }; + } + + /** 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 { 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 +590,193 @@ } } + async function handleSave() { + if (!objectKey) return; + if (isTooLargeToEdit()) { + addToast('error', m.storage_editor_too_large({ limit: formatFileSize(maxEditableFileSize) })); + return; + } + if (preview.kind !== 'text') return; + + saving = true; + try { + await storage.api.saveText({ + bucket, + key: objectKey, + body: editorText, + originalSize: preview.totalSize, + previewBytes: preview.previewBytes, + contentType: preview.contentType + }); + + originalText = editorText; + addToast('success', m.storage_editor_saved()); + } catch (err) { + if (err instanceof StorageError) { + 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 { + addToast('error', 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 isTooLargeToEdit()} +
+
+ {/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' && !isTooLargeToEdit()} + + + {/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..62e48f3b 100644 --- a/src/lib/components/storage/modals/PreviewModal.svelte.spec.ts +++ b/src/lib/components/storage/modals/PreviewModal.svelte.spec.ts @@ -1,93 +1,98 @@ 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);
   }
-  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)
+    }
+  });
 }
 
-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 +101,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 +109,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 +272,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();
     });
   });
 
@@ -575,13 +805,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 +818,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 +826,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 +834,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..e8247c3e
--- /dev/null
+++ b/src/lib/components/storage/modals/RenameModal.svelte
@@ -0,0 +1,96 @@
+
+
+ !loading}>
+  
+
diff --git a/src/lib/components/storage/modals/StorageModals.svelte b/src/lib/components/storage/modals/StorageModals.svelte
index a3d8f9f0..47f408aa 100644
--- a/src/lib/components/storage/modals/StorageModals.svelte
+++ b/src/lib/components/storage/modals/StorageModals.svelte
@@ -1,27 +1,41 @@
 
 
 {#if storage.activeModal?.type === 'delete'}
@@ -55,6 +107,9 @@
     bind:open={previewOpen}
     bucket={storage.bucket}
     objectKey={storage.activeModal.payload.key}
+    archiveKey={storage.activeModal.payload.archiveKey}
+    archivePath={storage.activeModal.payload.archivePath}
+    nestedArchivePath={storage.activeModal.payload.nestedArchivePath}
   />
 {/if}
 
@@ -66,3 +121,72 @@
     onSuccess={storage.handleUploadSuccess}
   />
 {/if}
+
+{#if storage.activeModal?.type === 'details'}
+  
+{/if}
+
+{#if storage.activeModal?.type === 'rename'}
+  {@const modalPayload = storage.activeModal.payload}
+   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}
+   {
+      const connectionId = connectionStore.activeConnectionId;
+      if (!connectionId) return true;
+      try {
+        const fullKey = modalPayload.destPrefix + entry.customName.trim();
+        return !(await checkObjectExists(modalPayload.bucket, fullKey, connectionId));
+      } catch {
+        return true;
+      }
+    }}
+    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..cc8bcc6f 100644
--- a/src/lib/components/storage/modals/StorageModals.svelte.spec.ts
+++ b/src/lib/components/storage/modals/StorageModals.svelte.spec.ts
@@ -12,14 +12,7 @@ vi.mock('$app/paths', () => ({
 // 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..0a7768d4
--- /dev/null
+++ b/src/lib/components/storage/modals/details/BucketDetails.svelte
@@ -0,0 +1,263 @@
+
+
+
+ {#if loading} +
+ +
+ {/if} + + {#if error} + + {/if} + + {#if details} +
+

+ {m.storage_details_versioning()} +

+
+ {#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.grants.length > 0} +
+

+ {m.storage_details_permissions()} +

+
+ {#each details.acl.grants as grant (grant.grantee + grant.permission)} +
+
+ {/each} +
+
+ {/if} + + {#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 && 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..1d173c30 --- /dev/null +++ b/src/lib/components/storage/modals/details/DirectoryDetails.svelte @@ -0,0 +1,356 @@ + + +
+ {#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()}{meta.bucketOwner}
{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} +
+
+
+
{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..fa39ae9c --- /dev/null +++ b/src/lib/components/storage/modals/details/DirectorySizeList.svelte @@ -0,0 +1,147 @@ + + +
+ + + + + + + + + + + {#each sortedItems as item (item.name)} + + + + + + + {/each} + +
+ + + + + +
+ {item.name} + + {formatFileSize(item.size)} + + {#if item.lastModified} + + {:else} + + {/if} +
+
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..653c6a1b --- /dev/null +++ b/src/lib/components/storage/modals/details/Treemap.svelte @@ -0,0 +1,390 @@ + + +
+ + + {#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..fc4c2e79 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/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..a682fc4b --- /dev/null +++ b/src/lib/components/storage/modals/preview/ParquetPreview.svelte @@ -0,0 +1,319 @@ + + +{#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 93% rename from src/lib/components/storage/modals/upload/UploadConflictEntry.svelte rename to src/lib/components/storage/modals/shared/ConflictEntry.svelte index 68fc270a..41cbcc9c 100644 --- a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte +++ b/src/lib/components/storage/modals/shared/ConflictEntry.svelte @@ -2,10 +2,10 @@ 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 type { ConflictEntry, Resolution } from './conflict-types.js'; interface Props { - entry: FileEntry; + entry: ConflictEntry; onSetResolution: (resolution: Resolution) => void; onSetCustomName: (name: string) => void; onRenameButtonClick: () => void; @@ -17,22 +17,21 @@ const uid = $props.id(); - let nameOnly = $derived(entry.targetKey.split('/').at(-1) ?? entry.file.name); let badRename = $derived( entry.resolution === 'rename' && - (entry.customName.trim() === '' || entry.customName.trim() === entry.file.name) + (entry.customName.trim() === '' || entry.customName.trim() === entry.originalName) );
  • - “{nameOnly}” + “{entry.originalName}”

    + +
    + +
      + {#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} +
    + +
    + + +
    + + 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..e487a083 --- /dev/null +++ b/src/lib/components/storage/modals/shared/conflict-types.ts @@ -0,0 +1,14 @@ +export type Resolution = 'replace' | 'skip' | 'rename'; + +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; +} 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..5843c0cc 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,193 @@ 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 }); - - await expect - .element(page.getByRole('textbox', { name: 'New file name' })) - .toBeInTheDocument(); + 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(); }); - it('should show error when renameState is conflict', async () => { - const file = new File(['x'], 'original.txt'); - const entry = makeEntry({ - file, - resolution: 'rename', - renameState: 'conflict', - customName: 'different-name.txt' - }); - render(UploadConflictEntry, { entry, ...defaultCallbacks }); - - await expect - .element(page.getByRole('alert')) - .toHaveTextContent('This name already exists here.'); + 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 customName is empty (badRename)', async () => { + it('should show a check icon when rename is confirmed', async () => { const entry = makeEntry({ resolution: 'rename', - renameState: 'editing', - customName: ' ' + 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')).toBeInTheDocument(); + 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 matches original file name', async () => { - const file = new File(['x'], 'original.txt'); + it('should show an error when new name matches the original', async () => { const entry = makeEntry({ - file, resolution: 'rename', renameState: 'editing', - customName: 'original.txt' + 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 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(); + const screen = render(ConflictEntry, { entry, ...defaultCallbacks, onRenameButtonClick }); + await screen.getByRole('button', { name: /Confirm name/ }).click(); + expect(onRenameButtonClick).toHaveBeenCalledOnce(); }); - 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'); - }); - - 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/UploadDropzone.svelte.spec.ts b/src/lib/components/storage/modals/upload/UploadDropzone.svelte.spec.ts index 2a98156c..848b7859 100644 --- a/src/lib/components/storage/modals/upload/UploadDropzone.svelte.spec.ts +++ b/src/lib/components/storage/modals/upload/UploadDropzone.svelte.spec.ts @@ -38,13 +38,17 @@ describe('UploadDropzone', () => { it('should have Select files button', async () => { render(UploadDropzone, { onFilesSelected: vi.fn() }); - await expect.element(page.getByRole('button', { name: 'Select files' })).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Select files' }).first()) + .toBeInTheDocument(); }); it('should have Select folder button', async () => { render(UploadDropzone, { onFilesSelected: vi.fn() }); - await expect.element(page.getByRole('button', { name: 'Select folder' })).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Select folder' }).first()) + .toBeInTheDocument(); }); it('should have hidden file inputs', async () => { @@ -359,7 +363,7 @@ describe('UploadDropzone', () => { const fileInput = page.getByLabelText('Select files').element() as HTMLInputElement; const clickSpy = vi.spyOn(fileInput, 'click'); - const btn = page.getByRole('button', { name: 'Select files' }); + const btn = page.getByRole('button', { name: 'Select files' }).first(); (btn.element() as HTMLElement).click(); expect(clickSpy).toHaveBeenCalled(); @@ -371,7 +375,7 @@ describe('UploadDropzone', () => { const dirInput = page.getByLabelText('Select folder').element() as HTMLInputElement; const clickSpy = vi.spyOn(dirInput, 'click'); - const btn = page.getByRole('button', { name: 'Select folder' }); + const btn = page.getByRole('button', { name: 'Select folder' }).first(); (btn.element() as HTMLElement).click(); expect(clickSpy).toHaveBeenCalled(); diff --git a/src/lib/components/storage/modals/upload/UploadModal.svelte b/src/lib/components/storage/modals/upload/UploadModal.svelte index baa67985..244d6c74 100644 --- a/src/lib/components/storage/modals/upload/UploadModal.svelte +++ b/src/lib/components/storage/modals/upload/UploadModal.svelte @@ -6,13 +6,15 @@ import IconCheckCircle from 'virtual:icons/material-symbols/check-circle'; import * as m from '$lib/paraglide/messages.js'; import Modal from '$lib/components/Modal.svelte'; - import { checkObjectExists, uploadFile, UploadError } from '$lib/storage/upload.js'; + import { checkObjectExists, uploadFile } from '$lib/storage/upload.js'; + import { StorageError } from '$lib/storage/errors.js'; import { formatFileSize } from '$lib/storage/utils.js'; - import { loadConnectionLocally, getConnectionHeader } from '$lib/storage/connection-storage.js'; + import { connectionStore } from '$lib/storage/connection-store.svelte.js'; import { uploadConcurrency } from '$lib/client/feature-flags.js'; import UploadDropzone from './UploadDropzone.svelte'; - import UploadConflictEntry from './UploadConflictEntry.svelte'; import UploadEntryStatus from './UploadEntryStatus.svelte'; + import ConflictEntry from '../shared/ConflictEntry.svelte'; + import type { ConflictEntry as ConflictEntryType } from '../shared/conflict-types.js'; import type { FileEntry, Phase, Resolution, RenameState } from './types.js'; interface Props { @@ -103,8 +105,7 @@ cancelRequested = false; phase = 'checking'; - const conn = loadConnectionLocally(); - const connHeader = conn ? getConnectionHeader(conn) : ''; + const connHeader = connectionStore.activeConnectionId ?? ''; const results: { id: string; conflict: boolean }[] = []; for (let i = 0; i < entries.length; i += uploadConcurrency) { @@ -183,8 +184,7 @@ entries = entries.map((e) => e.id === entry.id ? { ...e, status: 'uploading' as const, progress: 0 } : e ); - const conn = loadConnectionLocally(); - const connHeader = conn ? getConnectionHeader(conn) : ''; + const connHeader = connectionStore.activeConnectionId ?? ''; try { await uploadFile( bucket, @@ -200,7 +200,7 @@ ); } catch (err) { const msg = - err instanceof UploadError ? mapUploadError(err) : m.storage_upload_error_unknown(); + err instanceof StorageError ? mapUploadError(err) : m.storage_upload_error_unknown(); entries = entries.map((e) => e.id === entry.id ? { ...e, status: 'error' as const, errorMessage: msg } : e ); @@ -274,8 +274,7 @@ entries = entries.map((e) => (e.id === id ? { ...e, renameState: 'checking' as const } : e)); const newKey = resolvedKey(entry); - const conn = loadConnectionLocally(); - const connHeader = conn ? getConnectionHeader(conn) : ''; + const connHeader = connectionStore.activeConnectionId ?? ''; try { const exists = await checkObjectExists(bucket, newKey, connHeader); const nextState: RenameState = exists ? 'conflict' : 'ok'; @@ -308,7 +307,7 @@ // ── Error mapping ────────────────────────────────────────────────────────── - function mapUploadError(err: UploadError): string { + function mapUploadError(err: StorageError): string { switch (err.code) { case 'not_connected': return m.storage_upload_error_not_connected(); @@ -333,13 +332,15 @@

    {m.storage_upload_title()}

    - +
    + +
    @@ -421,8 +422,16 @@ aria-label={m.storage_upload_conflicts_title()} > {#each conflictEntries as entry (entry.id)} - setResolution(entry.id, res)} onSetCustomName={(name) => setCustomName(entry.id, name)} onRenameButtonClick={() => handleRenameButtonClick(entry.id)} diff --git a/src/lib/components/storage/modals/upload/UploadModal.svelte.spec.ts b/src/lib/components/storage/modals/upload/UploadModal.svelte.spec.ts index 759502bd..3d4ca814 100644 --- a/src/lib/components/storage/modals/upload/UploadModal.svelte.spec.ts +++ b/src/lib/components/storage/modals/upload/UploadModal.svelte.spec.ts @@ -3,27 +3,24 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { faker } from '@faker-js/faker'; import UploadModal from './UploadModal.svelte'; +import { StorageError } from '$lib/storage/errors.js'; -const { mockCheckObjectExists, mockUploadFile, MockUploadError } = vi.hoisted(() => { +const { mockCheckObjectExists, mockUploadFile } = vi.hoisted(() => { const mockCheckObjectExists = vi.fn().mockResolvedValue(false); const mockUploadFile = vi.fn().mockResolvedValue(undefined); - class MockUploadError extends Error { - code: string; - constructor(code: string, message: string) { - super(message); - this.code = code; - this.name = 'UploadError'; - } - } - - return { mockCheckObjectExists, mockUploadFile, MockUploadError }; + return { mockCheckObjectExists, mockUploadFile }; }); vi.mock('$lib/storage/upload.js', () => ({ checkObjectExists: mockCheckObjectExists, - uploadFile: mockUploadFile, - UploadError: MockUploadError + uploadFile: mockUploadFile +})); + +// Provide a stable, isolated connection store so mutations from other test +// files (e.g. StorageConnectForm.svelte.spec.ts) cannot bleed across. +vi.mock('$lib/storage/connection-store.svelte.js', () => ({ + connectionStore: { activeConnectionId: null, connections: [] } })); const defaultProps = { @@ -327,7 +324,7 @@ describe('UploadModal', () => { }); it('should show error state when upload fails with UploadError (access_denied)', async () => { - mockUploadFile.mockRejectedValue(new MockUploadError('access_denied', 'Access denied')); + mockUploadFile.mockRejectedValue(new StorageError('access_denied', 'Access denied')); render(UploadModal, defaultProps); await selectAndUpload([createFile('a.txt')]); @@ -346,7 +343,7 @@ describe('UploadModal', () => { }); it('should handle not_connected error', async () => { - mockUploadFile.mockRejectedValue(new MockUploadError('not_connected', 'Not connected')); + mockUploadFile.mockRejectedValue(new StorageError('not_connected', 'Not connected')); render(UploadModal, defaultProps); await selectAndUpload([createFile('a.txt')]); @@ -355,7 +352,7 @@ describe('UploadModal', () => { }); it('should handle no_such_bucket error', async () => { - mockUploadFile.mockRejectedValue(new MockUploadError('no_such_bucket', 'No such bucket')); + mockUploadFile.mockRejectedValue(new StorageError('no_such_bucket', 'No such bucket')); render(UploadModal, defaultProps); await selectAndUpload([createFile('a.txt')]); @@ -364,7 +361,7 @@ describe('UploadModal', () => { }); it('should handle invalid_part error', async () => { - mockUploadFile.mockRejectedValue(new MockUploadError('invalid_part', 'Invalid part')); + mockUploadFile.mockRejectedValue(new StorageError('invalid_part', 'Invalid part')); render(UploadModal, defaultProps); await selectAndUpload([createFile('a.txt')]); @@ -373,7 +370,7 @@ describe('UploadModal', () => { }); it('should handle server_error', async () => { - mockUploadFile.mockRejectedValue(new MockUploadError('server_error', 'Server error')); + mockUploadFile.mockRejectedValue(new StorageError('server_error', 'Server error')); render(UploadModal, defaultProps); await selectAndUpload([createFile('a.txt')]); @@ -382,7 +379,7 @@ describe('UploadModal', () => { }); it('should show error filename in error list', async () => { - mockUploadFile.mockRejectedValue(new MockUploadError('access_denied', 'Denied')); + mockUploadFile.mockRejectedValue(new StorageError('access_denied', 'Denied')); render(UploadModal, defaultProps); await selectAndUpload([createFile('secret.txt')]); @@ -676,7 +673,7 @@ describe('UploadModal', () => { let callIdx = 0; mockUploadFile.mockImplementation(async () => { callIdx++; - if (callIdx === 2) throw new MockUploadError('access_denied', 'Denied'); + if (callIdx === 2) throw new StorageError('access_denied', 'Denied'); }); render(UploadModal, defaultProps); diff --git a/src/lib/components/storage/modals/upload/types.ts b/src/lib/components/storage/modals/upload/types.ts index 61bd2321..80c0cf59 100644 --- a/src/lib/components/storage/modals/upload/types.ts +++ b/src/lib/components/storage/modals/upload/types.ts @@ -1,18 +1,11 @@ /** * Shared types for the upload modal sub-components. + * Resolution and RenameState are re-exported from the shared conflict types. */ -export type Resolution = 'replace' | 'skip' | 'rename'; +import type { Resolution, RenameState } from '../shared/conflict-types.js'; -/** - * Tracks the two-stage rename confirmation flow. - * idle - rename not selected for this entry - * editing - rename selected, text field is editable - * checking - async conflict check in progress - * ok - new name confirmed available - * conflict - new name already exists in the bucket - */ -export type RenameState = 'idle' | 'editing' | 'checking' | 'ok' | 'conflict'; +export type { Resolution, RenameState }; export type FileEntry = { id: string; diff --git a/src/lib/components/storage/shared/FloatingMenu.svelte b/src/lib/components/storage/shared/FloatingMenu.svelte new file mode 100644 index 00000000..261a6d06 --- /dev/null +++ b/src/lib/components/storage/shared/FloatingMenu.svelte @@ -0,0 +1,52 @@ + + +{#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..33047ce8 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 5548e608..a4f2cd54 100644 --- a/src/lib/components/trino/StatementResult.svelte +++ b/src/lib/components/trino/StatementResult.svelte @@ -162,12 +162,14 @@ {#if result.trinoQueryUrl || result.columns.length > 0}
    {#if result.trinoQueryUrl} + + {m.trino_view_in_trino()} = CAPS[category]) break; } } @@ -82,11 +84,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(); } @@ -95,6 +99,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/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/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 2972e467..84603c46 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,6 +94,7 @@ export const auth = betterAuth({ return { name: profile.name || fullName || profile.preferred_username || profile.email, email: profile.email || profile.preferred_username, + emailVerified: profile.email_verified ?? true, image: profile.picture || null, username: trinoUser || profile.preferred_username || profile.email }; diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 00000000..3b056e21 --- /dev/null +++ b/src/lib/server/db.ts @@ -0,0 +1,49 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import { logger } from './logging'; + +const log = logger.child({ module: 'database' }); + +// Parse connection credentials from environment variables +const dbHost = process.env.DATABASE_HOST || 'localhost'; +const dbPort = parseInt(process.env.DATABASE_PORT || '31432', 10); +const dbName = process.env.DATABASE_NAME || 'cockpit'; +const dbUser = process.env.DATABASE_USER || 'cockpit'; +if (!process.env.DATABASE_PASSWORD) { + log.warn('DATABASE_PASSWORD not set, using default development password'); +} +const dbPassword = process.env.DATABASE_PASSWORD || 'cockpit-dev-password'; + +// SSL is disabled in development (local k8s), enabled in production +const isDev = process.env.NODE_ENV !== 'production'; +const sslMode = isDev ? false : true; + +// 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..1a9cdbbc --- /dev/null +++ b/src/lib/server/feature-flags.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { parseParquetDisallowed } from './feature-flags.js'; + +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..6289074f 100644 --- a/src/lib/server/feature-flags.ts +++ b/src/lib/server/feature-flags.ts @@ -2,6 +2,7 @@ // from the live process env so consumers don't repeat the env-name + parsing. import { env } from '$env/dynamic/private'; +import { env as publicEnv } from '$env/dynamic/public'; /** When `STACKABLE_COCKPIT_COMPLETION_ENABLED=false`, the SQL editor's * code-completion provider is not registered and the metadata endpoint @@ -15,6 +16,16 @@ export const completionEnabled = env.STACKABLE_COCKPIT_COMPLETION_ENABLED !== 'f * credentials and the file-browser UI. */ export const storageBrowserEnabled = env.STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED === 'true'; +// ── Infinite scroll preview ─────────────────────────────────────────────────── + +/** When `STACKABLE_COCKPIT_INFINITE_SCROLL_ENABLED=false`, the CSV and Parquet + * file previews load a fixed number of rows without virtual scrolling / infinite + * loading. Enabled by default — the previews fetch row chunks lazily as the + * user scrolls, reducing S3 costs and browser memory for large files. + * Controlled by `STACKABLE_COCKPIT_INFINITE_SCROLL_ENABLED`. Default: `true`. */ +export const infiniteScrollEnabled = + env.PUBLIC_STACKABLE_COCKPIT_INFINITE_SCROLL_ENABLED !== 'false'; + // ── Storage preview limits ─────────────────────────────────────────────────── /** Maximum bytes fetched when streaming a text, CSV, or JSON file preview. @@ -51,3 +62,85 @@ 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; + +// ── 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' +); + +// ── Storage browser: Text editor ───────────────────────────────────────────── + +/** Maximum file size (in bytes) that may be saved via the inline text editor. + * Files with an `originalSize` exceeding this limit are treated as read-only + * and save requests are rejected with HTTP 413. Mirrors the client-side flag + * `maxEditableFileSize` so that the restriction is enforced even if the client + * check is bypassed. + * Controlled by `PUBLIC_STACKABLE_COCKPIT_MAX_EDITABLE_FILE_SIZE`. Default: 5242880 (5 MiB). */ +export const maxEditableFileSize: number = (() => { + const parsed = parseInt(publicEnv.PUBLIC_STACKABLE_COCKPIT_MAX_EDITABLE_FILE_SIZE ?? '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 5 * 1024 * 1024; +})(); + +// ── Storage browser: Context actions (cut, copy, paste, rename) ──────────── + +/** When `PUBLIC_STACKABLE_COCKPIT_STORAGE_CUT_COPY_ENABLED=true`, the context + * menu shows Cut/Copy actions. Uses the PUBLIC_ key so the same env var gates + * both client and server. Disabled by default. */ +export const storageCutCopyEnabled = + (publicEnv.PUBLIC_STACKABLE_COCKPIT_STORAGE_CUT_COPY_ENABLED ?? 'false') === 'true'; + +/** When `PUBLIC_STACKABLE_COCKPIT_STORAGE_PASTE_ENABLED=true`, the context + * menu shows Paste and the paste API endpoint is active. + * Uses PUBLIC_ key. Disabled by default. */ +export const storagePasteEnabled = + (publicEnv.PUBLIC_STACKABLE_COCKPIT_STORAGE_PASTE_ENABLED ?? 'false') === 'true'; + +/** When `PUBLIC_STACKABLE_COCKPIT_STORAGE_RENAME_ENABLED=true`, the rename + * API endpoint is active. Uses PUBLIC_ key. Disabled by default. */ +export const storageRenameEnabled = + (publicEnv.PUBLIC_STACKABLE_COCKPIT_STORAGE_RENAME_ENABLED ?? 'false') === 'true'; + +/** When `PUBLIC_STACKABLE_COCKPIT_STORAGE_MOVE_ENABLED=true`, the move + * API endpoint (drag-and-drop) is active. Uses PUBLIC_ key. Disabled by default. */ +export const storageMoveEnabled = + (publicEnv.PUBLIC_STACKABLE_COCKPIT_STORAGE_MOVE_ENABLED ?? 'false') === 'true'; diff --git a/src/lib/server/migrate.ts b/src/lib/server/migrate.ts new file mode 100644 index 00000000..fed65f1d --- /dev/null +++ b/src/lib/server/migrate.ts @@ -0,0 +1,29 @@ +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { db } from './db.js'; +import { logger } from './logging/index.js'; + +const log = logger.child({ module: 'migrations' }); + +export async function runMigrations() { + try { + log.info('Running database migrations...'); + await migrate(db, { migrationsFolder: './src/lib/server/migrations' }); + log.info('Database migrations completed successfully'); + return true; + } catch (error) { + log.error({ error }, '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/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/_journal.json b/src/lib/server/migrations/meta/_journal.json new file mode 100644 index 00000000..8cc9f72d --- /dev/null +++ b/src/lib/server/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1789716908296, + "tag": "0000_initial_schema", + "breakpoints": true + } + ] +} diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts new file mode 100644 index 00000000..b27777d5 --- /dev/null +++ b/src/lib/server/schema.ts @@ -0,0 +1,27 @@ +import { pgTable, text, timestamp, uuid, jsonb, index, unique } 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) + ] +); diff --git a/src/lib/server/storage/archive.test.ts b/src/lib/server/storage/archive.test.ts new file mode 100644 index 00000000..369f257c --- /dev/null +++ b/src/lib/server/storage/archive.test.ts @@ -0,0 +1,477 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; +import { readFileSync, unlinkSync, existsSync, mkdtempSync } from 'node:fs'; +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')); + +import { + getArchiveFormat, + listArchiveContents, + extractArchiveEntry, + clearArchiveCache, + type ArchiveDownloadFn +} from './archive.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; +} + +const dummyDownloadFn: ArchiveDownloadFn = (key: string) => { + // eslint-disable-next-line security/detect-non-literal-fs-filename + const buf = readFileSync(key); + return Promise.resolve( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buf)); + controller.close(); + } + }) + ); +}; + +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('detects .rar', () => { + expect(getArchiveFormat('archive.rar')).toBe('rar'); + }); + + it('detects .7z', () => { + expect(getArchiveFormat('archive.7z')).toBe('7z'); + }); + + 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, vi.fn()); + + 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, + vi.fn() + ); + + 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, + vi.fn() + ); + + 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, vi.fn()); + + 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, + vi.fn() + ); + + 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, vi.fn()); + + 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, + vi.fn() + ); + + 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, vi.fn()); + + 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']); + }); + }); + + 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, + vi.fn(), + '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('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, + vi.fn() + ); + + 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, + vi.fn() + ); + + 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, + vi.fn() + ); + + 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, + vi.fn() + ); + + 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, + vi.fn(), + '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, vi.fn()); + expect(first.entries).toHaveLength(1); + expect(downloadSpy).toHaveBeenCalledTimes(1); + + const second = await listArchiveContents('test-bucket', path, '', downloadSpy, vi.fn()); + 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) => { + const zip = new AdmZip(); + zip.addFile('file.txt', Buffer.from(contents)); + const data = zip.toBuffer(); + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(data)); + controller.close(); + } + }); + }; + 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, + vi.fn(), + undefined, + undefined, + 'connection-a' + ); + const second = await listArchiveContents( + 'shared-bucket', + 'archive.zip', + '', + secondDownload, + vi.fn(), + undefined, + undefined, + 'connection-b' + ); + + expect(firstDownload).toHaveBeenCalledOnce(); + expect(secondDownload).toHaveBeenCalledOnce(); + expect(first.entries).toHaveLength(1); + expect(second.entries).toHaveLength(1); + clearArchiveCache(); + }); +}); diff --git a/src/lib/server/storage/archive.ts b/src/lib/server/storage/archive.ts new file mode 100644 index 00000000..5e74b088 --- /dev/null +++ b/src/lib/server/storage/archive.ts @@ -0,0 +1,1003 @@ +import { + createReadStream, + createWriteStream, + existsSync, + mkdtempSync, + statSync, + writeFileSync, + rmSync +} from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, extname, resolve, sep } from 'node:path'; +import { createGunzip } from 'node:zlib'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import AdmZip from 'adm-zip'; +import * as tar from 'tar-stream'; +import { Readable } from 'node:stream'; +import { logger } from '$lib/server/logging'; + +const log = logger.child({ module: 'archive-service' }); + +const execFileAsync = promisify(execFile); + +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; +} + +const CACHE_TTL = 30 * 60 * 1000; +const CLEANUP_INTERVAL = 60_000; + +interface CacheEntry { + path: string; + expiresAt: number; + bucket: string; + key: string; +} + +const archiveCache = new Map(); + +const cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [cacheKey, entry] of archiveCache) { + if (now > entry.expiresAt) { + try { + const dir = join(entry.path, '..'); + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (existsSync(dir)) rmRecursive(dir); + } catch (err) { + log.warn({ err, cache_key: cacheKey }, 'failed to clean up archive temp file'); + } + archiveCache.delete(cacheKey); + } + } +}, CLEANUP_INTERVAL); + +if (cleanupTimer.unref) cleanupTimer.unref(); + +function cacheKey(connectionId: string, bucket: string, key: string): string { + return `${connectionId}:${bucket}:${key}`; +} + +function getCachedPath(connectionId: string, bucket: string, key: string): string | null { + const entry = archiveCache.get(cacheKey(connectionId, bucket, key)); + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (entry && Date.now() < entry.expiresAt && existsSync(entry.path)) { + return entry.path; + } + if (entry) archiveCache.delete(cacheKey(connectionId, bucket, key)); + return null; +} + +function cacheArchive(connectionId: string, bucket: string, key: string, path: string): void { + archiveCache.set(cacheKey(connectionId, bucket, key), { + path, + expiresAt: Date.now() + CACHE_TTL, + bucket, + key + }); +} + +function streamToTempFile(stream: ReadableStream, ext: string): Promise { + return new Promise((resolve, reject) => { + const tmpDir = mkdtempSync(join(tmpdir(), 'archive-')); + const tmpPath = join(tmpDir, `archive${ext}`); + // eslint-disable-next-line security/detect-non-literal-fs-filename + const writable = createWriteStream(tmpPath); + const nodeStream = Readable.fromWeb(stream as import('stream/web').ReadableStream); + + function cleanup() { + try { + rmRecursive(tmpDir); + } catch { + /* noop */ + } + } + + nodeStream.pipe(writable); + nodeStream.on('error', (err) => { + writable.destroy(); + cleanup(); + reject(err); + }); + writable.on('finish', () => resolve(tmpPath)); + writable.on('error', (err) => { + cleanup(); + reject(err); + }); + }); +} + +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 }, + { ext: '.rar', formats: ['rar'] as const }, + { ext: '.7z', formats: ['7z'] 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); + }); +} + +// ── RAR ────────────────────────────────────────────────────────────────────── + +async function listRar(tempPath: string, internalPrefix: string): Promise { + const prefix = internalPrefix ? ensureTrailingSlash(normalizePath(internalPrefix)) : ''; + + try { + const { stdout } = await execFileAsync('unrar', ['lb', tempPath], { timeout: 30000 }); + const allEntries = stdout + .split('\n') + .map((l) => normalizePath(l.trim())) + .filter(Boolean); + + const seenDirs = new Set(); + const entries: ArchiveEntry[] = []; + + for (const entryPath of allEntries) { + const isDir = entryPath.endsWith('/'); + const path = entryPath; + + if (!path.startsWith(prefix)) continue; + const relative = path.slice(prefix.length); + if (!relative) continue; + + if (isDir) { + if (!relative.includes('/') && !seenDirs.has(relative)) { + seenDirs.add(relative); + entries.push({ + key: relative, + size: 0, + lastModified: new Date(0), + isDirectory: true + }); + } + } else { + if (relative.includes('/')) { + const dirName = relative.split('/')[0] + '/'; + if (!seenDirs.has(dirName)) { + seenDirs.add(dirName); + entries.push({ key: dirName, size: 0, lastModified: new Date(0), isDirectory: true }); + } + } else { + entries.push({ + key: relative, + size: 0, + lastModified: new Date(0), + isDirectory: false + }); + } + } + } + + return { entries, hasMore: false }; + } catch (err) { + throw new Error( + 'RAR support requires the "unrar" command to be installed on the server. ' + + (err instanceof Error ? err.message : String(err)) + ); + } +} + +async function extractRarEntry( + tempPath: string, + internalPath: string, + maxBytes?: number +): Promise { + const tmpDir = mkdtempSync(join(tmpdir(), 'rar-extract-')); + try { + const { stdout } = await execFileAsync('unrar', ['p', '-inul', tempPath, internalPath], { + timeout: 30000, + maxBuffer: maxBytes ?? 100 * 1024 * 1024 + }); + if (!stdout) return null; + return Buffer.from(stdout, 'binary'); + } catch (err) { + throw new Error('RAR extraction failed: ' + (err instanceof Error ? err.message : String(err))); + } finally { + try { + rmRecursive(tmpDir); + } catch { + /* noop */ + } + } +} + +// ── 7z ─────────────────────────────────────────────────────────────────────── + +const SEVEN_ZIP_BINARIES = ['7zz', '7zr', '7z']; + +async function find7zBinary(): Promise { + for (const bin of SEVEN_ZIP_BINARIES) { + try { + await execFileAsync('which', [bin], { timeout: 5000 }); + return bin; + } catch { + /* noop */ + } + } + return null; +} + +async function list7z( + tempPath: string, + internalPrefix: string, + maxBytes?: number +): Promise { + const bin = await find7zBinary(); + if (!bin) throw new Error('7z support requires 7-Zip to be installed on the server.'); + + const prefix = internalPrefix ? ensureTrailingSlash(normalizePath(internalPrefix)) : ''; + + try { + const { stdout } = await execFileAsync(bin, ['l', '-slt', '-ba', tempPath], { timeout: 30000 }); + return parse7zListing(stdout, prefix, maxBytes); + } catch (err) { + throw new Error('7z listing failed: ' + (err instanceof Error ? err.message : String(err))); + } +} + +function parse7zListing(stdout: string, prefix: string, maxBytes?: number): ArchiveListing { + const lines = stdout + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + const entries: ArchiveEntry[] = []; + const seenDirs = new Set(); + let currentPath = ''; + let currentSize = 0; + let isDir = false; + let totalDecompressed = 0; + + for (const line of lines) { + if (line.startsWith('Path = ')) { + const path = normalizePath(line.slice(6).trim()); + if (currentPath && currentPath.startsWith(prefix)) { + const relative = currentPath.slice(prefix.length); + if (relative) { + if (isDir && !relative.includes('/') && !seenDirs.has(ensureTrailingSlash(relative))) { + seenDirs.add(ensureTrailingSlash(relative)); + entries.push({ + key: ensureTrailingSlash(relative), + size: 0, + lastModified: new Date(0), + isDirectory: true + }); + } else if (!isDir) { + if (maxBytes !== undefined) { + totalDecompressed += currentSize; + if (totalDecompressed > maxBytes) { + return { entries: [], hasMore: false, tooLarge: true }; + } + } + if (relative.includes('/')) { + const dirName = relative.split('/')[0] + '/'; + if (!seenDirs.has(dirName)) { + seenDirs.add(dirName); + entries.push({ + key: dirName, + size: 0, + lastModified: new Date(0), + isDirectory: true + }); + } + } else { + entries.push({ + key: relative, + size: currentSize, + lastModified: new Date(0), + isDirectory: false + }); + } + } + } + } + currentPath = path; + currentSize = 0; + isDir = false; + } else if (line.startsWith('Size = ')) { + currentSize = parseInt(line.slice(6).trim(), 10) || 0; + } else if (line.startsWith('Folder = ')) { + isDir = line.slice(8).trim() === '+'; + } + } + + if (currentPath && currentPath.startsWith(prefix)) { + const relative = currentPath.slice(prefix.length); + if (relative) { + if (isDir && !relative.includes('/') && !seenDirs.has(ensureTrailingSlash(relative))) { + entries.push({ + key: ensureTrailingSlash(relative), + size: 0, + lastModified: new Date(0), + isDirectory: true + }); + } else if (!isDir) { + entries.push({ + key: relative, + size: currentSize, + lastModified: new Date(0), + isDirectory: false + }); + } + } + } + + return { entries, hasMore: false }; +} + +async function extract7zEntry( + tempPath: string, + internalPath: string, + maxBytes?: number +): Promise { + const bin = await find7zBinary(); + if (!bin) throw new Error('7z support requires 7-Zip to be installed on the server.'); + + if (internalPath.includes('..')) { + throw new Error('Path traversal rejected'); + } + + const tmpDir = mkdtempSync(join(tmpdir(), '7z-extract-')); + try { + await execFileAsync(bin, ['x', '-y', `-o${tmpDir}`, tempPath, internalPath], { + timeout: 60000 + }); + const resolvedPath = resolve(tmpDir, internalPath); + if (!resolvedPath.startsWith(tmpDir + sep)) { + throw new Error('Path traversal rejected'); + } + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (!existsSync(resolvedPath)) return null; + // Check on disk before reading the entry into memory. + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (maxBytes !== undefined && statSync(resolvedPath).size > maxBytes) return null; + // eslint-disable-next-line security/detect-non-literal-fs-filename + return readFile(resolvedPath); + } catch (err) { + throw new Error('7z extraction failed: ' + (err instanceof Error ? err.message : String(err))); + } finally { + try { + rmRecursive(tmpDir); + } catch { + /* noop */ + } + } +} + +function rmRecursive(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +export type ArchiveDownloadFn = (key: string) => Promise; +export type ArchiveMetadataFn = (key: string) => Promise<{ size: number; contentType?: string }>; + +/** + * Resolve the archive file to a local temp path. + * For nested archives, first download the outer archive, then extract the nested one. + */ +async function resolveArchivePath( + connectionId: string, + bucket: string, + key: string, + nestedArchivePath: string | undefined, + downloadFn: ArchiveDownloadFn, + maxBytes?: number +): Promise { + let tempPath = getCachedPath(connectionId, bucket, key); + if (!tempPath) { + log.info({ bucket, key }, 'downloading archive'); + const ext = extname(key) || '.bin'; + const stream = await downloadFn(key); + tempPath = await streamToTempFile(stream, ext); + cacheArchive(connectionId, bucket, key, tempPath); + } + + if (!nestedArchivePath) return tempPath; + + // Resolve nested archive — cache it under a composite key + const nestedCacheKey = cacheKey(connectionId, bucket, `${key}!/${nestedArchivePath}`); + const cachedEntry = archiveCache.get(nestedCacheKey); + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (cachedEntry && Date.now() < cachedEntry.expiresAt && existsSync(cachedEntry.path)) { + return cachedEntry.path; + } + if (cachedEntry) archiveCache.delete(nestedCacheKey); + + log.info({ bucket, key, nested_archive_path: nestedArchivePath }, 'extracting nested archive'); + + // Extract nested archive from outer archive + const format = getArchiveFormat(key); + if (!format) throw new Error(`Unsupported archive format: ${key}`); + + let nestedData: Buffer | null = null; + const normNestedPath = normalizePath(nestedArchivePath); + + switch (format) { + case 'zip': + nestedData = extractZipEntry(tempPath, normNestedPath, maxBytes) ?? null; + break; + case 'tar': + nestedData = await extractTarEntry(tempPath, normNestedPath, maxBytes); + break; + case 'tar.gz': + case 'tgz': + nestedData = await extractTarGzEntry(tempPath, normNestedPath, maxBytes); + break; + case 'rar': + nestedData = await extractRarEntry(tempPath, normNestedPath, maxBytes); + break; + case '7z': + nestedData = await extract7zEntry(tempPath, normNestedPath, maxBytes); + break; + } + + if (!nestedData) throw new Error(`Nested archive "${nestedArchivePath}" not found in ${key}`); + + // Save nested archive to temp file and cache it + const nestedExt = extname(nestedArchivePath) || '.bin'; + const nestedDir = mkdtempSync(join(tmpdir(), 'archive-nested-')); + const nestedPath = join(nestedDir, `archive${nestedExt}`); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + writeFileSync(nestedPath, nestedData); + + archiveCache.set(nestedCacheKey, { + path: nestedPath, + expiresAt: Date.now() + CACHE_TTL, + bucket, + key: `${key}!/${nestedArchivePath}` + }); + + return nestedPath; +} + +/** + * List the contents of an archive at a given internal prefix. + * Downloads the archive from S3 once and caches it server-side for 30 minutes. + * Supports nested archives via the optional `nestedArchivePath` parameter. + */ +export async function listArchiveContents( + bucket: string, + key: string, + internalPrefix: string, + downloadFn: ArchiveDownloadFn, + metadataFn: ArchiveMetadataFn, + nestedArchivePath?: string, + maxBytes?: number, + connectionId = '' +): Promise { + const effectiveKey = nestedArchivePath ? `${key}!/${nestedArchivePath}` : key; + const format = getArchiveFormat(effectiveKey); + if (!format) throw new Error(`Unsupported archive format: ${key}`); + + // Pre-check: if compressed size exceeds the limit, bail out before downloading + if (maxBytes !== undefined && !nestedArchivePath) { + try { + const meta = await metadataFn(key); + if (meta.size > maxBytes) { + log.info( + { bucket, key, compressed_size: meta.size, max_bytes: maxBytes }, + 'archive compressed size exceeds limit' + ); + return { entries: [], hasMore: false, tooLarge: true }; + } + } catch { + // metadataFn failure is non-fatal — proceed with download + } + } + + const tempPath = await resolveArchivePath( + connectionId, + bucket, + key, + nestedArchivePath, + downloadFn, + maxBytes + ); + + log.debug( + { + bucket, + key, + internal_prefix: internalPrefix, + nested_archive_path: nestedArchivePath, + format + }, + 'listing archive contents' + ); + + switch (format) { + case 'zip': + return listZip(tempPath, internalPrefix, maxBytes); + case 'tar': + return listTar(tempPath, internalPrefix, maxBytes); + case 'tar.gz': + case 'tgz': + return listTarGz(tempPath, internalPrefix, maxBytes); + case 'rar': + return listRar(tempPath, internalPrefix); + case '7z': + return list7z(tempPath, internalPrefix, maxBytes); + default: + throw new Error(`Unsupported archive format: ${format}`); + } +} + +/** + * Extract a single file from an archive and return its content as a Buffer. + * Supports nested archives via the optional `nestedArchivePath` parameter. + */ +export async function extractArchiveEntry( + bucket: string, + key: string, + internalPath: string, + downloadFn: ArchiveDownloadFn, + metadataFn: ArchiveMetadataFn, + nestedArchivePath?: string, + maxBytes?: number, + connectionId = '' +): Promise { + const effectiveKey = nestedArchivePath ? `${key}!/${nestedArchivePath}` : key; + const format = getArchiveFormat(effectiveKey); + if (!format) throw new Error(`Unsupported archive format: ${key}`); + + // Pre-check: if compressed size exceeds the limit, bail out before downloading + if (maxBytes !== undefined && !nestedArchivePath) { + try { + const meta = await metadataFn(key); + if (meta.size > maxBytes) { + log.info( + { bucket, key, compressed_size: meta.size, max_bytes: maxBytes }, + 'archive compressed size exceeds limit, extraction aborted' + ); + return null; + } + } catch { + // metadataFn failure is non-fatal + } + } + + const tempPath = await resolveArchivePath( + connectionId, + bucket, + key, + nestedArchivePath, + downloadFn, + maxBytes + ); + + log.debug( + { bucket, key, internal_path: internalPath, nested_archive_path: nestedArchivePath, format }, + 'extracting archive entry' + ); + + const normalizedPath = normalizePath(internalPath); + + let data: Buffer | null = null; + switch (format) { + case 'zip': + data = extractZipEntry(tempPath, normalizedPath, maxBytes) ?? null; + break; + case 'tar': + data = await extractTarEntry(tempPath, normalizedPath, maxBytes); + break; + case 'tar.gz': + case 'tgz': + data = await extractTarGzEntry(tempPath, normalizedPath, maxBytes); + break; + case 'rar': + data = await extractRarEntry(tempPath, normalizedPath, maxBytes); + break; + case '7z': + data = await extract7zEntry(tempPath, normalizedPath, maxBytes); + break; + default: + return null; + } + + // 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; +} + +/** + * Clean up all cached archive temp files. Useful for testing. + */ +export function clearArchiveCache(): void { + for (const [cacheKey, entry] of archiveCache) { + try { + const dir = join(entry.path, '..'); + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (existsSync(dir)) rmRecursive(dir); + } catch { + /* noop */ + } + archiveCache.delete(cacheKey); + } +} diff --git a/src/lib/server/storage/connection.ts b/src/lib/server/storage/connection.ts index 772bfba2..beabe993 100644 --- a/src/lib/server/storage/connection.ts +++ b/src/lib/server/storage/connection.ts @@ -1,58 +1,87 @@ import { error } from '@sveltejs/kit'; -import { StorageConnectionSchema } from '$lib/storage/schemas.js'; -import { STORAGE_CONNECTION_HEADER } from '$lib/storage/connection-storage.js'; +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'; const log = logger.child({ module: 'storage-connection' }); +/** The shape of the JSON stored inside encrypted_payload. */ +interface StoredPayload { + host: string; + port?: number; + tls?: { verification: 'Full' | 'None' }; + accessStyle: 'Path' | 'VirtualHosted'; + region: { name: string }; + credentials?: { accessKey: string; secretKey: 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 updates `updated_at` on the connection row (fire-and-forget) so that + * auto-connect picks the most-recently-used connection on the next page load. + * + * 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. */ -function parseConnectionPayload(raw: string): S3ConnectionConfig { - let data: unknown; - try { - data = JSON.parse(atob(raw)); - } catch { - throw error(400, 'Invalid storage connection header'); - } +export async function getConnectionFromHeader( + request: Request, + userId: string +): Promise { + const connectionId = request.headers.get(STORAGE_CONNECTION_ID_HEADER); + if (!connectionId) return null; - 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 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'); } - if (parsed.data.type !== 's3') { - throw error(400, 'Storage backend not supported'); + const row = rows[0]; + + let payload: StoredPayload; + try { + payload = JSON.parse(decrypt(row.encryptedPayload, storageEncryptionKey())) as StoredPayload; + } catch (err) { + log.error({ err, connection_id: connectionId }, 'failed to decrypt storage connection payload'); + throw error(500, 'Failed to decrypt storage connection'); } - const { host, port, tls, accessStyle, region, credentials } = parsed.data; + // Update updated_at asynchronously — do not block the request on this. + 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 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: (row.additionalBuckets as string[]) ?? [] }; } - -/** - * 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); -} 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..e55a5ef4 --- /dev/null +++ b/src/lib/server/storage/connections-db.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { select, insert, remove } = vi.hoisted(() => ({ + select: vi.fn(), + insert: vi.fn(), + remove: 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 } +})); +vi.mock('./encryption-key.js', () => ({ storageEncryptionKey: () => Buffer.alloc(32, 1) })); + +import { deleteConnection, getConnectionForUser, 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(); + }); + + 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('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('deletes only the requested user connection', async () => { + const where = vi.fn().mockResolvedValue(undefined); + remove.mockReturnValue({ where }); + + await deleteConnection('user-id', 'connection-id'); + expect(where).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/server/storage/connections-db.ts b/src/lib/server/storage/connections-db.ts new file mode 100644 index 00000000..80b0b8a4 --- /dev/null +++ b/src/lib/server/storage/connections-db.ts @@ -0,0 +1,136 @@ +import { eq, and } from 'drizzle-orm'; +import { db } from '$lib/server/db.js'; +import { 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'; + +const log = logger.child({ module: 'connections-db' }); + +/** Stored payload shape inside encrypted_payload. */ +interface StoredPayload { + host: string; + port?: number; + tls?: { verification: 'Full' | 'None' }; + accessStyle: 'Path' | 'VirtualHosted'; + region: { name: string }; + credentials?: { accessKey: string; secretKey: string }; +} + +/** + * 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, + 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: StoredPayload = { + 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) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('user_storage_connections_user_id_name_unique')) { + 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 = JSON.parse( + decrypt(rows[0].encryptedPayload, storageEncryptionKey()) + ) as StoredPayload; + 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 + .delete(userStorageConnections) + .where( + and(eq(userStorageConnections.id, connectionId), eq(userStorageConnections.userId, userId)) + ); + log.info({ connection_id: connectionId }, 'storage connection deleted'); +} diff --git a/src/lib/server/storage/copy-move.ts b/src/lib/server/storage/copy-move.ts new file mode 100644 index 00000000..9ffd43bb --- /dev/null +++ b/src/lib/server/storage/copy-move.ts @@ -0,0 +1,105 @@ +import type { StorageProvider } from './provider.js'; +import type pino from 'pino'; +import { json } from '@sveltejs/kit'; +import { processKeysSequentially } from './operations.js'; +import { createProgressStream, type StreamableOpResult } from './streaming.js'; +import { createJob } from './job-store.js'; + +export interface PerformCopyOrMoveOptions { + provider: StorageProvider; + sourceKeys: string[]; + destinationPrefix: string; + destinationKey?: string; + streamProgress: boolean; + logger: pino.Logger; + bucket: string; + jobId?: string; + deleteOriginals: boolean; +} + +export async function performCopyOrMove(options: PerformCopyOrMoveOptions): Promise { + const { + provider, + sourceKeys, + destinationPrefix, + destinationKey, + streamProgress, + logger, + bucket, + jobId, + deleteOriginals + } = options; + + const operationName = deleteOriginals ? 'move' : 'copy'; + const resultKey = deleteOriginals ? 'moved' : 'results'; + + if (!streamProgress) { + const { succeeded, failed } = await processKeysSequentially( + provider, + sourceKeys, + destinationPrefix, + { logger, bucket, deleteOriginals }, + destinationKey + ); + return json({ [resultKey]: succeeded, failed }); + } + + if (jobId) { + createJob(jobId); + } + + const buildCompletePayload = deleteOriginals + ? (result: StreamableOpResult) => ({ + type: 'complete' as const, + moved: result.results, + failed: result.failed + }) + : undefined; + + return createProgressStream( + async (emit) => { + const progressReported = new Set(); + const result = await processKeysSequentially( + provider, + sourceKeys, + destinationPrefix, + { + onCopyProgress: (sourceKey, destKey, loaded, total) => { + progressReported.add(sourceKey); + emit({ type: 'progress', sourceKey, destKey, loaded, total }); + }, + onCopySuccess: async (sourceKey, destKey) => { + if (!progressReported.has(sourceKey)) { + try { + const meta = await provider.getMetadata(sourceKey); + emit({ type: 'progress', sourceKey, destKey, loaded: meta.size, total: meta.size }); + } catch { + // Metadata fetch failed — skip synthetic progress + } + } + progressReported.delete(sourceKey); + emit({ type: 'done', sourceKey, destKey }); + }, + onCopyFailed: (sourceKey, destKey, error) => { + emit({ type: 'failed', sourceKey, error }); + }, + onBeforeDelete: (keys) => { + emit({ type: 'status', message: `Deleting ${keys.length} original(s)` }); + }, + logger, + bucket, + deleteOriginals + }, + destinationKey + ); + return { results: result.succeeded, failed: result.failed }; + }, + { + jobId, + operationName, + logger, + bucket, + buildCompletePayload + } + ); +} diff --git a/src/lib/server/storage/directory-tree.ts b/src/lib/server/storage/directory-tree.ts new file mode 100644 index 00000000..e63e13b2 --- /dev/null +++ b/src/lib/server/storage/directory-tree.ts @@ -0,0 +1,102 @@ +import type { TreemapNode, DirectoryChildItem } from '$lib/storage/details-types.js'; + +function trieToTreemap(prefix: string, node: TrieNode): TreemapNode { + function convert(n: TrieNode, parentPrefix: string, isRoot: boolean): TreemapNode { + const result: TreemapNode = { + name: n.name, + size: n.size + }; + if (n.children.size > 0) { + result.children = [...n.children.entries()] + .map(([, v]) => convert(v, isRoot ? '' : parentPrefix + n.name + '/', false)) + .sort((a, b) => b.size - a.size); + } else { + result.path = parentPrefix || undefined; + result.fullKey = prefix + (parentPrefix || '') + n.name; + } + return result; + } + return convert(node, '', true); +} + +interface TrieNode { + name: string; + size: number; + children: Map; +} + +export function buildTree(prefix: string, keys: Array<{ key: string; size: number }>): TreemapNode { + const rootName = prefix.split('/').filter(Boolean).pop() || '(root)'; + + const rootTrie: TrieNode = { name: rootName, size: 0, children: new Map() }; + + for (const { key, size } of keys) { + const relative = key.slice(prefix.length); + const parts = relative.split('/').filter(Boolean); + if (parts.length === 0) continue; + + rootTrie.size += size; + let current = rootTrie; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!current.children.has(part)) { + current.children.set(part, { name: part, size: 0, children: new Map() }); + } + current = current.children.get(part)!; + current.size += size; + } + } + + return trieToTreemap(prefix, rootTrie); +} + +export function buildChildrenByDepth( + prefix: string, + keys: Array<{ key: string; size: number; lastModified?: Date }>, + maxDepth: number +): Record { + const depthMaps: Map[] = []; + for (let d = 1; d <= maxDepth; d++) { + depthMaps.push(new Map()); + } + + for (const { key, size, lastModified } of keys) { + const relative = key.slice(prefix.length); + const parts = relative.split('/').filter(Boolean); + if (parts.length === 0) continue; + + for (let d = 1; d <= maxDepth; d++) { + if (parts.length < d) continue; + + const nameParts = parts.slice(0, d); + const index = d - 1; + const isDir = index < parts.length - 1 || relative.endsWith('/'); + const childName = isDir ? nameParts.join('/') + '/' : nameParts.join('/'); + + const map = depthMaps[d - 1]; + const existing = map.get(childName); + if (existing) { + existing.size += size; + if (lastModified && (!existing.lastModified || lastModified > existing.lastModified)) { + existing.lastModified = lastModified; + } + } else { + map.set(childName, { size, lastModified }); + } + } + } + + const result: Record = {}; + for (let d = 1; d <= maxDepth; d++) { + result[d] = [...depthMaps[d - 1].entries()] + .map(([name, entry]) => ({ + name, + size: entry.size, + lastModified: entry.lastModified?.toISOString(), + isDirectory: name.endsWith('/') + })) + .sort((a, b) => b.size - a.size); + } + + return result; +} diff --git a/src/lib/server/storage/download-tokens.ts b/src/lib/server/storage/download-tokens.ts new file mode 100644 index 00000000..35fe821e --- /dev/null +++ b/src/lib/server/storage/download-tokens.ts @@ -0,0 +1,38 @@ +import type { S3ConnectionConfig } from './types.js'; + +interface TokenEntry { + config: S3ConnectionConfig; + createdAt: number; +} + +const tokens = new Map(); +const TOKEN_TTL_MS = 60_000; +const CLEANUP_INTERVAL_MS = 30_000; + +let lastCleanup = Date.now(); + +function cleanupExpired(): void { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + const cutoff = now - TOKEN_TTL_MS; + for (const [key, entry] of tokens) { + if (entry.createdAt < cutoff) tokens.delete(key); + } +} + +export function createDownloadToken(config: S3ConnectionConfig): string { + cleanupExpired(); + const token = crypto.randomUUID(); + tokens.set(token, { config, createdAt: Date.now() }); + return token; +} + +export function consumeDownloadToken(token: string): S3ConnectionConfig | null { + cleanupExpired(); + const entry = tokens.get(token); + if (!entry) return null; + tokens.delete(token); + if (Date.now() - entry.createdAt > TOKEN_TTL_MS) return null; + return entry.config; +} 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..b9db3d5b --- /dev/null +++ b/src/lib/server/storage/encryption.test.ts @@ -0,0 +1,107 @@ +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('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..41ba8173 --- /dev/null +++ b/src/lib/server/storage/encryption.ts @@ -0,0 +1,84 @@ +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; + }, + key: Buffer +): string { + const hmacKey = deriveHmacSubkey(key); + const material = [ + credentials.endpoint, + credentials.region, + credentials.accessKeyId, + credentials.secretAccessKey + ].join('|'); + return createHmac('sha256', hmacKey).update(material, 'utf8').digest('hex'); +} diff --git a/src/lib/server/storage/hdfs-provider.ts b/src/lib/server/storage/hdfs-provider.ts index 5861498a..c3b1d5ba 100644 --- a/src/lib/server/storage/hdfs-provider.ts +++ b/src/lib/server/storage/hdfs-provider.ts @@ -1,6 +1,7 @@ import type { StorageProvider, ObjectDownload, DeleteObjectsResult } from './provider.js'; import type { HDFSConfig } from './types.js'; import type { StoragePage, StorageMetadata } from '$lib/storage/types.js'; +import type { LifecycleRule, BucketAcl } 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 +11,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, @@ -50,4 +55,31 @@ export class HDFSStorageProvider implements StorageProvider { listAllKeys(_prefix: string): Promise { throw new Error('HDFS not implemented'); } + + listAllKeysProgressively( + _prefix: string, + _onBatch: (keys: Array<{ key: string; size: number }>) => void + ): 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> { + throw new Error('HDFS not implemented'); + } + + getBucketAcl(): Promise { + throw new Error('HDFS not implemented'); + } + + copyObject(_sourceKey: string, _destKey: string): Promise { + throw new Error('HDFS not implemented'); + } } diff --git a/src/lib/server/storage/job-store.ts b/src/lib/server/storage/job-store.ts new file mode 100644 index 00000000..f865d24a --- /dev/null +++ b/src/lib/server/storage/job-store.ts @@ -0,0 +1,96 @@ +import { logger } from '$lib/server/logging'; + +const log = logger.child({ module: 'job-store' }); + +export interface JobProgress { + completedCount: number; + completedBytes: number; + currentFileName?: string; +} + +interface JobEntry { + status: 'running' | 'done' | 'error'; + result: T | null; + error?: string; + createdAt: number; + progress: JobProgress; +} + +const store = new Map>(); + +const RUNNING_TTL = 30 * 60 * 1000; +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.createdAt > ttl) { + store.delete(id); + log.trace({ job_id: id, status: entry.status }, 'cleaned up expired job'); + } + } + }, CLEANUP_INTERVAL); +} + +startCleanup(); + +/** + * Create a new job entry. + */ +export function createJob(id: string): void { + store.set(id, { + status: 'running', + result: null, + createdAt: Date.now(), + progress: { completedCount: 0, completedBytes: 0 } + }); +} + +/** + * 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; + 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) return; + entry.status = 'done' as const; + (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) return; + entry.status = 'error' as const; + entry.error = error; + log.trace({ job_id: id, error }, 'job failed'); +} + +/** + * Get the current state of a job. Returns null if not found (expired or never existed). + */ +export function getJob(id: string): JobEntry | null { + return (store.get(id) as JobEntry) ?? null; +} diff --git a/src/lib/server/storage/operations.ts b/src/lib/server/storage/operations.ts new file mode 100644 index 00000000..bc7ea2a1 --- /dev/null +++ b/src/lib/server/storage/operations.ts @@ -0,0 +1,204 @@ +import type { StorageProvider } from './provider.js'; +import type pino from 'pino'; + +export interface DestEntry { + sourceKey: string; + baseDestKey: string; +} + +export interface ProcessKeysOptions { + onCopySuccess?: (sourceKey: string, destKey: string) => void | Promise; + onCopyProgress?: ( + sourceKey: string, + destKey: string, + loaded: number, + total: number + ) => void | Promise; + onCopyFailed?: ( + sourceKey: string, + destKey: string, + error: string, + errorName?: string, + stack?: string + ) => void | Promise; + onBeforeDelete?: (keys: string[]) => void | Promise; + onDeleteFailed?: (key: string, error: string) => void | Promise; + logger?: pino.Logger; + bucket?: string; + deleteOriginals?: boolean; +} + +export interface ProcessKeysResult { + succeeded: Array<{ sourceKey: string; destKey: string }>; + failed: Array<{ sourceKey: string; error: string }>; +} + +/** + * Compute copy/move destinations for each source key, expanding directories + * to their full recursive listing while preserving the relative path + * structure under the destination prefix. + */ +export async function computeDestinations( + provider: StorageProvider, + sourceKeys: string[], + destinationPrefix: string +): Promise { + const destinationGroups = await Promise.all( + sourceKeys.map(async (key): Promise => { + const name = key.endsWith('/') + ? key.split('/').filter(Boolean).pop() + '/' + : key.split('/').pop(); + + if (!key.endsWith('/')) { + return [{ sourceKey: key, baseDestKey: destinationPrefix + name }]; + } + const children = await provider.listAllKeys(key); + return [ + { sourceKey: key, baseDestKey: destinationPrefix + name }, + ...children + .filter((child) => child !== key) + .map((child) => ({ + sourceKey: child, + baseDestKey: destinationPrefix + name + child.slice(key.length) + })) + ]; + }) + ); + + return destinationGroups.flat(); +} + +/** + * Given a desired destination key, check if it already exists and generate + * a unique name by appending ` (1)`, ` (2)`, etc. before the extension. + * Returns the first key that does not exist. + */ +export async function uniqueDestKey(provider: StorageProvider, baseKey: string): Promise { + if (!(await provider.exists(baseKey))) return baseKey; + + const name = baseKey.endsWith('/') ? baseKey.slice(0, -1) : baseKey; + const lastDot = name.lastIndexOf('.'); + const stem = lastDot > 0 ? name.slice(0, lastDot) : name; + const ext = lastDot > 0 && !baseKey.endsWith('/') ? name.slice(lastDot) : ''; + const suffix = baseKey.endsWith('/') ? '/' : ''; + + let counter = 1; + while (true) { + const candidate = `${stem} (${counter})${ext}${suffix}`; + if (!(await provider.exists(candidate))) return candidate; + counter++; + } +} + +/** + * Process a list of source keys sequentially, copying each to a unique + * destination under the given prefix. Handles unique-key resolution, + * optional progress reporting, failure logging, and originals deletion. + * + * This is the shared core used by both the non-streaming and streaming + * code paths in copy-move operations. + */ +export async function processKeysSequentially( + provider: StorageProvider, + sourceKeys: string[], + destinationPrefix: string, + options: ProcessKeysOptions = {}, + destinationKey?: string +): Promise { + const { + onCopySuccess, + onCopyProgress, + onCopyFailed, + onBeforeDelete, + onDeleteFailed, + logger, + bucket, + deleteOriginals + } = options; + + const destinations = destinationKey + ? [{ sourceKey: sourceKeys[0]!, baseDestKey: destinationKey }] + : await computeDestinations(provider, sourceKeys, destinationPrefix); + const succeeded: Array<{ sourceKey: string; destKey: string }> = []; + const failed: Array<{ sourceKey: string; error: string }> = []; + + const operationName = deleteOriginals ? 'move' : 'copy'; + const countKey = deleteOriginals ? 'moved' : 'copied'; + const failMsg = deleteOriginals ? 'move copy failed for key' : 'copy failed for key'; + + const resolvedDestinations: Array = []; + for (const { sourceKey, baseDestKey } of destinations) { + resolvedDestinations.push({ + sourceKey, + baseDestKey, + destKey: await uniqueDestKey(provider, baseDestKey) + }); + } + + await Promise.all( + resolvedDestinations.map(async ({ sourceKey, baseDestKey, destKey }) => { + try { + if (onCopyProgress) { + await provider.copyObject(sourceKey, destKey, (loaded, total) => { + onCopyProgress(sourceKey, destKey, loaded, total); + }); + } else { + await provider.copyObject(sourceKey, destKey); + } + succeeded.push({ sourceKey, destKey }); + await onCopySuccess?.(sourceKey, destKey); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + const errorName = err instanceof Error ? err.constructor.name : typeof err; + const stack = + err instanceof Error ? (err.stack ?? '').split('\n').slice(0, 3).join(' | ') : ''; + failed.push({ sourceKey, error: message }); + if (logger) { + logger.warn( + { + bucket, + source_key: sourceKey, + dest_key: baseDestKey, + error: message, + error_name: errorName, + stack + }, + failMsg + ); + } + await onCopyFailed?.(sourceKey, baseDestKey, message, errorName, stack); + } + }) + ); + + if (deleteOriginals && succeeded.length > 0) { + const keysToDelete = [...new Set(succeeded.map((s) => s.sourceKey))]; + if (keysToDelete.length > 0) { + await onBeforeDelete?.(keysToDelete); + const deleteResult = await provider.deleteObjects(keysToDelete); + for (const f of deleteResult.failed) { + failed.push({ sourceKey: f.key, error: f.message ?? 'Delete failed' }); + if (logger) { + logger.warn({ bucket, key: f.key }, 'move delete failed for key'); + } + await onDeleteFailed?.(f.key, f.message ?? 'Delete failed'); + } + } + } + + if (logger) { + logger.info( + { bucket, [countKey]: succeeded.length, failed: failed.length }, + `${operationName} completed` + ); + } + + return { succeeded, failed }; +} + +/** + * Encode a value as a single NDJSON line (newline-delimited JSON). + */ +export function ndjsonLine(data: Record): string { + return JSON.stringify(data) + '\n'; +} diff --git a/src/lib/server/storage/preview/cache.ts b/src/lib/server/storage/preview/cache.ts new file mode 100644 index 00000000..09f28725 --- /dev/null +++ b/src/lib/server/storage/preview/cache.ts @@ -0,0 +1,44 @@ +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); + } + } +} + +export function previewCacheKey(bucket: string, key: string): string { + return `${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..18185dd2 --- /dev/null +++ b/src/lib/server/storage/preview/csv.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type pino from 'pino'; +import type { StorageProvider } from '$lib/server/storage/provider.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 = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + child: vi.fn(() => mockLog) +} as unknown as pino.Logger; + +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(), + listAllKeys: 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(); + return makeProvider(async (_key: string, start: number, end: number) => { + const slice = content.slice(start, end + 1); + return new ReadableStream({ + start(controller) { + controller.enqueue(enc.encode(slice)); + controller.close(); + } + }); + }); +} + +describe('getCsvPreview', () => { + 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('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..26bd674c --- /dev/null +++ b/src/lib/server/storage/preview/csv.ts @@ -0,0 +1,319 @@ +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, 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; + 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 text = new TextDecoder('utf-8', { fatal: false }).decode(buffer); + + const textEndsWithNewline = text.endsWith('\n'); + const isLastByte = entry.bytesRead + bytesToRead >= entry.totalSize; + const lines = text.split('\n'); + + if (entry.headers.length === 0) { + if (lines.length > 0) { + entry.headers = parseCsvRow(lines[0].replace(/\r$/, '')); + } + let bytePos = entry.bytesRead; + // Advance past header + its \n (if \n is in this chunk) + const headerComplete = lines.length > 1; + for (let i = 0; i < lines[0].length + (headerComplete ? 1 : 0); i++) { + bytePos++; + } + for (let i = 1; i < lines.length; i++) { + // Defer the last line only if the chunk boundary falls mid-line + // (not when we've reached the end of the file) + const isLastElement = i === lines.length - 1; + const isIncomplete = !textEndsWithNewline && isLastElement && !isLastByte; + const hasOwnNewline = textEndsWithNewline || !isLastElement; + if (!isIncomplete && (lines[i].length > 0 || i < lines.length - 1)) { + entry.lineOffsets.push(bytePos); + } + if (!isIncomplete) { + bytePos += lines[i].length + (hasOwnNewline ? 1 : 0); + } + } + entry.bytesRead = bytePos; + } else { + let bytePos = entry.bytesRead; + for (let i = 0; i < lines.length; i++) { + // Defer the last line only if the chunk boundary falls mid-line + // (not when we've reached the end of the file) + const isLastElement = i === lines.length - 1; + const isIncomplete = !textEndsWithNewline && isLastElement && !isLastByte; + const hasOwnNewline = textEndsWithNewline || !isLastElement; + if (!isIncomplete && (lines[i].length > 0 || i < lines.length - 1)) { + entry.lineOffsets.push(bytePos); + } + if (!isIncomplete) { + bytePos += lines[i].length + (hasOwnNewline ? 1 : 0); + } + } + entry.bytesRead = bytePos; + } + 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 = '' +): Promise { + const log = requestLog.child({ module: 'csv-preview' }); + + 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(bucket, key); + let entry = csvPreviewCache.get(cacheKey); + const now = Date.now(); + + if (!entry) { + entry = { + headers: [], + lineOffsets: [], + bytesRead: 0, + totalSize, + 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..572c7bcb --- /dev/null +++ b/src/lib/server/storage/preview/parquet.test.ts @@ -0,0 +1,764 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type pino from 'pino'; +import type { StorageProvider } from '$lib/server/storage/provider.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 = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + child: vi.fn(() => mockLog) +} as unknown as pino.Logger; + +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(), + deleteObjects: vi.fn(), + listAllKeys: vi.fn(), + listAllKeysProgressively: 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'); + }); + + it('caches metadata in memory for subsequent calls', async () => { + 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' } }] + }); + + const provider = makeProvider({ + getMetadata: vi.fn().mockResolvedValue({ size: 1024, contentType: 'application/x-parquet' }), + getObjectRange: vi.fn(() => Promise.resolve(makeStream())) + }); + + // First call — parses metadata + await getParquetPreview(provider, 'cached.parquet', 0, 5, mockLog); + expect(mockParquetMetadataAsync).toHaveBeenCalledTimes(1); + + // Second call — should hit cache, not call parquetMetadataAsync again + await getParquetPreview(provider, 'cached.parquet', 0, 5, mockLog); + expect(mockParquetMetadataAsync).toHaveBeenCalledTimes(1); + }); + + 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..3b75e919 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, previewCacheKey } from './cache.js'; + +const fallbackLog = logger.child({ module: 'parquet-preview' }); /** * Override the pure-JS GZIP decompressor from hyparquet-compressors with @@ -11,20 +26,386 @@ 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. */ +const metadataCache = new ExpiringCache(); + +/** + * 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 +415,306 @@ 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, + totalSize?: number, + includeData = false, + bucket = '' ): 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' }); + const byteLength = totalSize ?? (await provider.getMetadata(key)).size; + + 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(bucket, key); + const cached = metadataCache.get(cacheKey); + if (cached) { + parquetMeta = cached; + log.debug({ key }, 'Used in-memory cached Parquet metadata'); + } else { + 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); + + metadataCache.set(cacheKey, parquetMeta); + } - 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..f148ffbb 100644 --- a/src/lib/server/storage/preview/stream.test.ts +++ b/src/lib/server/storage/preview/stream.test.ts @@ -7,6 +7,7 @@ const mockLog = { info: vi.fn(), debug: vi.fn(), warn: vi.fn() } as unknown as p function makeProvider(overrides: Partial = {}): StorageProvider { return { + listContainers: vi.fn(), listObjects: vi.fn(), getObject: vi .fn() @@ -17,6 +18,12 @@ function makeProvider(overrides: Partial = {}): StorageProvider putObject: vi.fn(), deleteObjects: vi.fn(), listAllKeys: vi.fn(), + listAllKeysProgressively: vi.fn(), + getBucketVersioning: vi.fn(), + getBucketLifecycleRules: vi.fn(), + getBucketTags: vi.fn(), + getBucketAcl: vi.fn(), + copyObject: vi.fn(), ...overrides }; } diff --git a/src/lib/server/storage/provider.ts b/src/lib/server/storage/provider.ts index 6156762d..27ef36c7 100644 --- a/src/lib/server/storage/provider.ts +++ b/src/lib/server/storage/provider.ts @@ -1,4 +1,5 @@ import type { StoragePage, StorageMetadata, DeleteObjectsResult } from '$lib/storage/types.js'; +import type { LifecycleRule, BucketAcl } from '$lib/storage/details-types.js'; export type { DeleteObjectsResult }; @@ -12,6 +13,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 @@ -43,4 +46,28 @@ export interface StorageProvider { * Used to expand directory prefixes before deletion. */ listAllKeys(prefix: string): Promise; + listAllKeysProgressively( + prefix: string, + onBatch: (keys: Array<{ key: string; size: number; lastModified?: Date }>) => void + ): Promise; + getBucketVersioning(): Promise; + getBucketLifecycleRules(): Promise; + getBucketTags(): Promise>; + getBucketAcl(): Promise; + /** + * Copy an object from `sourceKey` to `destKey` within the same bucket. + * For objects <= 5 GB uses S3 CopyObject; for larger objects streams the + * data through via multipart upload. Throws if the source does not exist + * or access is denied. Existing destination objects are silently overwritten. + * + * When provided, `onProgress` is called with `(loaded, total)` bytes during + * the multipart upload for large objects (> 5 GB). It is NOT called for + * small objects that use the native S3 CopyObject (which is server-side and + * has no streaming phase). + */ + copyObject( + sourceKey: string, + destKey: string, + onProgress?: (loaded: number, total: number) => void + ): Promise; } diff --git a/src/lib/server/storage/request-context.ts b/src/lib/server/storage/request-context.ts new file mode 100644 index 00000000..cee2190e --- /dev/null +++ b/src/lib/server/storage/request-context.ts @@ -0,0 +1,26 @@ +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'; + +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; +} + +export function requireConfig(event: RequestEvent) { + const config = event.locals.storageConfig; + if (!config) throw error(401, 'No storage connection configured'); + return config; +} + +export function createStorageProvider(event: RequestEvent): { + provider: StorageProvider; + bucket: string; +} { + const config = requireConfig(event); + const bucket = requireBucket(event); + return { provider: withStorageHttpErrors(getProvider(config, bucket)), bucket }; +} diff --git a/src/lib/server/storage/s3-client.test.ts b/src/lib/server/storage/s3-client.test.ts index 9dd465bd..8f2bae10 100644 --- a/src/lib/server/storage/s3-client.test.ts +++ b/src/lib/server/storage/s3-client.test.ts @@ -27,37 +27,61 @@ describe('createS3Client', () => { 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', () => { diff --git a/src/lib/server/storage/s3-client.ts b/src/lib/server/storage/s3-client.ts index 3b22292a..db4a85fd 100644 --- a/src/lib/server/storage/s3-client.ts +++ b/src/lib/server/storage/s3-client.ts @@ -12,6 +12,7 @@ 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' && { @@ -19,7 +20,6 @@ export function createS3Client(config: S3ConnectionConfig): S3Client { httpsAgent: new Agent({ rejectUnauthorized: false }) }) }), - 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.test.ts b/src/lib/server/storage/s3-provider.test.ts index a32bdc9d..97f69d90 100644 --- a/src/lib/server/storage/s3-provider.test.ts +++ b/src/lib/server/storage/s3-provider.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { S3ServiceException } from '@aws-sdk/client-s3'; +import { S3ServiceException, PutObjectCommand, CopyObjectCommand } 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 +367,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); + }); }); // --------------------------------------------------------------------------- @@ -489,6 +517,133 @@ describe('S3StorageProvider.listAllKeys', () => { }); }); +// --------------------------------------------------------------------------- +// copyObject +// --------------------------------------------------------------------------- + +describe('S3StorageProvider.copyObject', () => { + let provider: S3StorageProvider; + let send: ReturnType; + + beforeEach(() => { + ({ provider, send } = makeProvider()); + mockUploadDone.mockResolvedValue(undefined); + MockUpload.mockClear(); + }); + + 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('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}` + })) + } + }); + }); + + 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); + }); +}); + // --------------------------------------------------------------------------- // Constructor — uses createS3Client when no client injected // --------------------------------------------------------------------------- @@ -527,3 +682,184 @@ 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'])).rejects.toMatchObject({ status: 403 }); + }); + + it('deleteObjects: re-throws non-S3 errors', async () => { + send.mockRejectedValue(new Error('network')); + await expect(provider.deleteObjects(['file.txt'])).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/']); + + 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 }); // listAllKeys returns [] + send.mockResolvedValueOnce({ Errors: [] }); // deleteObjects + + await provider.deleteObjects(['empty/']); + + 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([]); + expect(result).toEqual({ failed: [] }); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/storage/s3-provider.ts b/src/lib/server/storage/s3-provider.ts index 56dadd73..598fade9 100644 --- a/src/lib/server/storage/s3-provider.ts +++ b/src/lib/server/storage/s3-provider.ts @@ -1,21 +1,46 @@ import { S3Client, S3ServiceException, + ListBucketsCommand, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, 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 type { S3Config } from './types.js'; import type { StoragePage, StorageObject, StorageMetadata } from '$lib/storage/types.js'; +import type { LifecycleRule, BucketAcl } 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' }); +/** 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,21 +50,37 @@ 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 + }) + ), + { bucket: this.bucket, operation: 'listObjects' } ); const objects: StorageObject[] = [ @@ -74,39 +115,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 +191,29 @@ 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 this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: Buffer.alloc(0), + ContentType: contentType, + ContentLength: 0 + }) + ); + return; + } + const upload = new Upload({ client: this.client, queueSize: 4, @@ -146,31 +226,59 @@ 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'); + if (keys.length === 0) { + return { failed: [] }; + } + + // Expand directory prefixes to their contained keys + const resolvedKeys: string[] = []; + for (const key of keys) { + if (key.endsWith('/')) { + const children = await this.listAllKeys(key); + if (children.length > 0) { + resolvedKeys.push(...children); + } else { + resolvedKeys.push(key); + } + } else { + resolvedKeys.push(key); + } + } + // 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); + for (let i = 0; i < resolvedKeys.length; i += MAX_KEYS) { + const chunk = resolvedKeys.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({ - Bucket: this.bucket, - Delete: { - Objects: chunk.map((key) => ({ Key: key })), - Quiet: true - } - }) + const output = await withS3Errors( + () => + this.client.send( + new DeleteObjectsCommand({ + Bucket: this.bucket, + Delete: { + Objects: chunk.map((key) => ({ Key: key })), + Quiet: true + } + }) + ), + { bucket: this.bucket, operation: 'deleteObjects' } ); const failed = (output.Errors ?? []).map((e) => ({ key: e.Key ?? '', @@ -189,6 +297,143 @@ export class S3StorageProvider implements StorageProvider { return { failed: allFailed }; } + async getBucketVersioning(): Promise<'Enabled' | 'Suspended' | 'Disabled'> { + try { + 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'; + } catch { + return 'Disabled'; + } + } + + async getBucketLifecycleRules(): Promise { + try { + 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 }] + : [] + }; + }); + } catch { + return []; + } + } + + async getBucketAcl(): Promise { + try { + 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 }; + } catch { + return { owner: 'Unknown', grants: [] }; + } + } + + async getBucketTags(): Promise> { + try { + 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; + } catch { + return {}; + } + } + + async listAllKeysProgressively( + prefix: string, + onBatch: (keys: Array<{ key: string; size: number; lastModified?: Date }>) => void + ): Promise { + log.trace({ bucket: this.bucket, prefix }, 'S3 ListObjectsV2 (progressive, concurrent)'); + + // Pipeline: while processing each page's results, the next page is already being + // fetched. Each page's continuation token is only known after its predecessor + // completes, so we chain through `.then()` to keep one lookahead fetch in-flight. + const inFlight: Array> = []; + + const fetchPage = (token?: string): Promise => + this.client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: prefix, + ContinuationToken: token + }) + ); + + // Seed the first fetch + inFlight.push(fetchPage()); + + while (inFlight.length > 0) { + const output = await inFlight.shift()!; + + const batch: Array<{ key: string; size: number; lastModified?: Date }> = []; + for (const obj of output.Contents ?? []) { + if (obj.Key) { + batch.push({ key: obj.Key, size: obj.Size ?? 0, lastModified: obj.LastModified }); + } + } + if (batch.length > 0) { + onBatch(batch); + } + + // If truncated, chain the next fetch so it starts while we process the + // current page's results (or already completes by the time we loop back). + if (output.IsTruncated && output.NextContinuationToken) { + const token = output.NextContinuationToken; + inFlight.push(fetchPage(token)); + } + } + + log.trace({ bucket: this.bucket, prefix }, 'progressive listing complete'); + } + async listAllKeys(prefix: string): Promise { log.trace({ bucket: this.bucket, prefix }, 'S3 ListObjectsV2 (recursive)'); const keys: string[] = []; @@ -214,4 +459,107 @@ export class S3StorageProvider implements StorageProvider { ); return keys; } + + async copyObject( + sourceKey: string, + destKey: string, + onProgress?: (loaded: number, total: number) => void + ): Promise { + log.trace({ bucket: this.bucket, 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 metadata = await this.getMetadata(sourceKey); + + if (metadata.size <= HEAD_LIMIT) { + await withS3Errors( + () => + this.client.send( + new CopyObjectCommand({ + Bucket: this.bucket, + CopySource: `/${this.bucket}/${encodeURIComponent(sourceKey)}`, + Key: destKey + }) + ), + { bucket: this.bucket, key: sourceKey, operation: 'copyObject' } + ); + return; + } + + log.info( + { bucket: this.bucket, source_key: sourceKey, size: metadata.size }, + 'object exceeds CopyObject limit, using server-side multipart copy' + ); + + await withS3Errors( + async () => { + const totalSize = metadata.size!; + // 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(totalSize / PART_SIZE); + + const { UploadId } = await this.client.send( + new CreateMultipartUploadCommand({ + Bucket: this.bucket, + Key: destKey, + ContentType: metadata.contentType ?? 'application/octet-stream' + }) + ); + const uploadId = UploadId!; + + const parts: Array<{ PartNumber: number; ETag: string }> = []; + try { + for (let i = 0; i < numParts; i++) { + const partNumber = i + 1; + const startByte = i * PART_SIZE; + const endByte = Math.min(startByte + PART_SIZE - 1, totalSize - 1); + + const { CopyPartResult } = await this.client.send( + new UploadPartCopyCommand({ + Bucket: this.bucket, + Key: destKey, + UploadId: uploadId, + PartNumber: partNumber, + CopySource: `/${this.bucket}/${encodeURIComponent(sourceKey)}`, + CopySourceRange: `bytes=${startByte}-${endByte}` + }) + ); + + parts.push({ + PartNumber: partNumber, + ETag: CopyPartResult?.ETag ?? '' + }); + + if (onProgress) { + onProgress(Math.min((i + 1) * PART_SIZE, totalSize), totalSize); + } + } + + await this.client.send( + new CompleteMultipartUploadCommand({ + Bucket: this.bucket, + Key: destKey, + UploadId: uploadId, + MultipartUpload: { Parts: parts } + }) + ); + } catch (err) { + try { + await this.client.send( + new AbortMultipartUploadCommand({ + Bucket: this.bucket, + Key: destKey, + UploadId: uploadId + }) + ); + } catch { + // best-effort cleanup + } + throw err; + } + }, + { bucket: this.bucket, key: sourceKey, operation: 'copyObject' } + ); + } } 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/streaming.ts b/src/lib/server/storage/streaming.ts new file mode 100644 index 00000000..62b1b82b --- /dev/null +++ b/src/lib/server/storage/streaming.ts @@ -0,0 +1,90 @@ +import { completeJob, failJob, updateJobProgress } from './job-store.js'; +import { ndjsonLine } from './operations.js'; +import type pino from 'pino'; + +export interface StreamableOpResult { + results: Array<{ sourceKey: string; destKey: string }>; + failed: Array<{ sourceKey: string; error: string }>; +} + +type ProgressEventBase = + | { type: 'progress'; sourceKey: string; destKey: string; loaded: number; total: number } + | { type: 'done'; sourceKey: string; destKey: string } + | { type: 'failed'; sourceKey: string; error: string }; + +type ProgressEvent = ProgressEventBase | { type: 'status'; message: string }; + +type EmitFn = (event: ProgressEvent) => void; + +/** + * Shared NDJSON streaming response helper. + * + * Sets up a ReadableStream that pipes progress/result events from an + * async operation to the client. Handles job-store integration and + * client disconnect gracefully. + */ +export function createProgressStream( + operation: (emit: EmitFn) => Promise, + options: { + jobId?: string; + operationName: string; + logger: pino.Logger; + bucket: string; + /** Build the complete event payload. Defaults to `{ type: 'complete', results, failed }`. */ + buildCompletePayload?: (result: StreamableOpResult) => Record; + } +): Response { + const encoder = new TextEncoder(); + let emitEvent: EmitFn = () => {}; + + const operationPromise = operation((event) => emitEvent(event)); + + const stream = new ReadableStream({ + async start(controller) { + emitEvent = (event) => { + if (options.jobId && event.type === 'progress') { + updateJobProgress(options.jobId, { completedBytes: event.loaded }); + } + + try { + controller.enqueue(encoder.encode(ndjsonLine(event))); + } catch { + // Controller closed (client disconnected) — operation continues + // in the background unaffected. + } + }; + + const result = await operationPromise; + + if (options.jobId) { + if (result.failed.length > 0) { + failJob( + options.jobId, + `Failed to ${options.operationName} ${result.failed.length} item(s)` + ); + } else { + completeJob(options.jobId, result); + } + } + + const completePayload = options.buildCompletePayload + ? options.buildCompletePayload(result) + : { type: 'complete', results: result.results, failed: result.failed }; + + try { + controller.enqueue(encoder.encode(ndjsonLine(completePayload))); + controller.close(); + } catch { + // Client already disconnected + } + } + }); + + 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/types.ts b/src/lib/server/storage/types.ts index fc3f71f9..bf371b64 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,10 @@ export type StorageConfig = S3Config | HDFSConfig; /** Per-user connection config stored in memory (no bucket). */ export type StorageConnectionConfig = S3ConnectionConfig | HDFSConfig; + +/** Minimal connection metadata returned to the client. */ +export interface ConnectionMetadata { + id: string; + name: string; + endpoint: string | null; +} 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/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 4fbf8131..8546ecdc 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..b5b9b88b --- /dev/null +++ b/src/lib/storage/api.spec.ts @@ -0,0 +1,544 @@ +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' } + }); +} + +function makeNdjsonLines(data: { + results?: Array<{ sourceKey: string; destKey: string }>; + moved?: Array<{ sourceKey: string; destKey: string }>; + failed?: Array<{ sourceKey: string; error: string }>; +}): string[] { + const items = data.results ?? data.moved ?? []; + const lines: string[] = []; + for (const r of items) { + lines.push(JSON.stringify({ type: 'done', sourceKey: r.sourceKey, destKey: r.destKey })); + } + for (const f of data.failed ?? []) { + lines.push(JSON.stringify({ type: 'failed', sourceKey: f.sourceKey, error: f.error })); + } + lines.push( + JSON.stringify({ + type: 'complete', + results: items, + failed: data.failed ?? [] + }) + ); + return lines; +} + +// ── 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('copy', () => { + it('sends a POST request with JSON body', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const copyResult = { + results: [{ sourceKey: 'a.txt', destKey: 'dest/a.txt' }], + failed: [] + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(copyResult)); + + const result = await api.copy({ + bucket: 'my-bucket', + sourceKeys: ['a.txt'], + destinationPrefix: 'dest/' + }); + + expect(result.results).toHaveLength(1); + expect(result.failed).toBe(0); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/copy'); + expect(url).toContain('bucket=my-bucket'); + expect(init?.method).toBe('POST'); + const body = JSON.parse(init?.body as string); + expect(body.sourceKeys).toEqual(['a.txt']); + expect(body.destinationPrefix).toBe('dest/'); + }); + + it('uses NDJSON streaming when progress=true', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const lines = makeNdjsonLines({ + results: [{ sourceKey: 'a.txt', destKey: 'dest/a.txt' }] + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ndjsonResponse(lines)); + + const result = await api.copy({ + bucket: 'my-bucket', + sourceKeys: ['a.txt'], + destinationPrefix: 'dest/', + progress: true + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0].sourceKey).toBe('a.txt'); + expect(result.failed).toBe(0); + }); + + it('includes jobId in the request body when provided', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ results: [], failed: [] })); + + await api.copy({ + bucket: 'b', + sourceKeys: ['x.txt'], + destinationPrefix: 'd/', + jobId: 'job-123' + }); + + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + const body = JSON.parse(init?.body as string); + expect(body.jobId).toBe('job-123'); + }); + + it('forwards AbortSignal', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ results: [], failed: [] })); + const controller = new AbortController(); + + await api.copy({ + bucket: 'b', + sourceKeys: ['x.txt'], + destinationPrefix: 'd/', + signal: controller.signal + }); + + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.signal).toBe(controller.signal); + }); + }); + + describe('move', () => { + it('sends a POST to /api/storage/move', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ moved: [{ sourceKey: 'a.txt', destKey: 'dest/a.txt' }], failed: [] }) + ); + + const result = await api.move({ + bucket: 'b', + sourceKeys: ['a.txt'], + destinationPrefix: 'dest/' + }); + + const [url] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toContain('/api/storage/move'); + expect(result.results).toHaveLength(1); + expect(result.results[0].sourceKey).toBe('a.txt'); + }); + + it('uses NDJSON streaming when progress=true', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const lines = makeNdjsonLines({ + results: [{ sourceKey: 'a.txt', destKey: 'dest/a.txt' }] + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ndjsonResponse(lines)); + + const result = await api.move({ + bucket: 'b', + sourceKeys: ['a.txt'], + destinationPrefix: 'dest/', + progress: true + }); + + expect(result.results).toHaveLength(1); + expect(result.failed).toBe(0); + }); + + it('reports failed items from NDJSON stream', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const lines = makeNdjsonLines({ + failed: [{ sourceKey: 'bad.txt', error: 'Access denied' }] + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ndjsonResponse(lines)); + + const result = await api.move({ + bucket: 'b', + sourceKeys: ['bad.txt'], + destinationPrefix: 'dest/', + progress: true + }); + + expect(result.results).toHaveLength(0); + expect(result.failed).toBe(1); + }); + }); + + 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('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'); + }); + }); + + 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('returns false when disconnected', async () => { + const api = createFetchStorageApi(() => null); + + const exists = await api.checkObjectExists({ bucket: 'b', key: 'file.txt' }); + expect(exists).toBe(false); + }); + }); + + describe('saveText', () => { + it('includes preview metadata required by the save endpoint', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + + await api.saveText({ + bucket: 'b', + key: 'new file.txt', + body: 'new text', + originalSize: 0, + previewBytes: 0, + contentType: 'text/plain' + }); + + 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('originalSize=0'); + expect(url).toContain('previewBytes=0'); + expect(url).toContain('contentType=text%2Fplain'); + expect(init?.method).toBe('POST'); + expect(init?.body).toBe('new text'); + }); + }); +}); + +describe('NDJSON streaming via copy', () => { + it('calls onProgress callback during streaming', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const onProgress = vi.fn(); + const lines = [ + JSON.stringify({ + type: 'progress', + sourceKey: 'big.bin', + destKey: 'dest/big.bin', + loaded: 50, + total: 100 + }), + JSON.stringify({ + type: 'progress', + sourceKey: 'big.bin', + destKey: 'dest/big.bin', + loaded: 100, + total: 100 + }), + JSON.stringify({ type: 'done', sourceKey: 'big.bin', destKey: 'dest/big.bin' }), + JSON.stringify({ + type: 'complete', + results: [{ sourceKey: 'big.bin', destKey: 'dest/big.bin' }], + failed: [] + }) + ]; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ndjsonResponse(lines)); + + await api.copy({ + bucket: 'b', + sourceKeys: ['big.bin'], + destinationPrefix: 'dest/', + progress: true, + callbacks: { onProgress } + }); + + expect(onProgress).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenCalledWith('big.bin', 'dest/big.bin', 50, 100); + expect(onProgress).toHaveBeenCalledWith('big.bin', 'dest/big.bin', 100, 100); + }); + + it('calls onDone callback for each completed item', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const onDone = vi.fn(); + const lines = [ + JSON.stringify({ type: 'done', sourceKey: 'a.txt', destKey: 'dest/a.txt' }), + JSON.stringify({ type: 'done', sourceKey: 'b.txt', destKey: 'dest/b.txt' }), + JSON.stringify({ + type: 'complete', + results: [ + { sourceKey: 'a.txt', destKey: 'dest/a.txt' }, + { sourceKey: 'b.txt', destKey: 'dest/b.txt' } + ], + failed: [] + }) + ]; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ndjsonResponse(lines)); + + await api.copy({ + bucket: 'b', + sourceKeys: ['a.txt', 'b.txt'], + destinationPrefix: 'dest/', + progress: true, + callbacks: { onDone } + }); + + expect(onDone).toHaveBeenCalledTimes(2); + expect(onDone).toHaveBeenCalledWith('a.txt', 'dest/a.txt'); + expect(onDone).toHaveBeenCalledWith('b.txt', 'dest/b.txt'); + }); + + it('calls onComplete with aggregated results', async () => { + const api = createFetchStorageApi(() => 'conn-1'); + const onComplete = vi.fn(); + const lines = makeNdjsonLines({ + results: [{ sourceKey: 'a.txt', destKey: 'dest/a.txt' }], + failed: [{ sourceKey: 'b.txt', error: 'Permission denied' }] + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ndjsonResponse(lines)); + + await api.copy({ + bucket: 'b', + sourceKeys: ['a.txt', 'b.txt'], + destinationPrefix: 'dest/', + progress: true, + callbacks: { onComplete } + }); + + expect(onComplete).toHaveBeenCalledOnce(); + const [results, failed] = onComplete.mock.calls[0]!; + expect(results).toHaveLength(1); + expect(failed).toHaveLength(1); + }); +}); diff --git a/src/lib/storage/api.test-utils.ts b/src/lib/storage/api.test-utils.ts new file mode 100644 index 00000000..9ca5e1cd --- /dev/null +++ b/src/lib/storage/api.test-utils.ts @@ -0,0 +1,106 @@ +/** + * 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, CopyMoveResult, DeleteResult, JobStatus } from './api.js'; +import type { StoragePage, ArchiveListingResponse } from './types.js'; +import type { FileDetails, DirectoryMetadata, BucketDetails } from './details-types.js'; + +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 copy(): Promise { + return { results: [], failed: 0 }; + }, + + async move(): Promise { + return { results: [], failed: 0 }; + }, + + 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 checkObjectExists() { + return false; + }, + + async download() { + return new Response(null, { status: 200 }); + }, + + async preview() { + return new Response(null, { status: 200 }); + }, + + async saveText() { + // no-op + }, + + 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..c4415380 --- /dev/null +++ b/src/lib/storage/api.ts @@ -0,0 +1,347 @@ +/** + * 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: + * - Mocking in tests via `createMemoryStorageApi()` + * - 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 { readNdjsonStream, type NdjsonStreamCallbacks } from './ndjson-stream.js'; +import type { StoragePage, ArchiveListingResponse } from './types.js'; +import type { FileDetails, DirectoryMetadata, BucketDetails } from './details-types.js'; + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface CopyMoveResult { + results: Array<{ sourceKey: string; destKey: string }>; + failed: number; +} + +export interface DeleteResult { + failed: Array<{ key: string; code?: string; message?: string }>; +} + +export interface JobStatus { + status: string; + progress?: { + completedCount?: number; + completedBytes?: number; + currentFileName?: string; + }; +} + +// ── Interface ────────────────────────────────────────────────────────────── + +export interface StorageApi { + list(params: { bucket: string; prefix?: string; pageSize?: number }): Promise; + + copy(params: { + bucket: string; + sourceKeys: string[]; + destinationPrefix: string; + progress?: boolean; + jobId?: string; + signal?: AbortSignal; + callbacks?: NdjsonStreamCallbacks; + }): Promise; + + move(params: { + bucket: string; + sourceKeys: string[]; + destinationPrefix: string; + destinationKey?: string; + progress?: boolean; + jobId?: string; + signal?: AbortSignal; + callbacks?: NdjsonStreamCallbacks; + }): 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; + + 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; + + saveText(params: { + bucket: string; + key: string; + body: string; + originalSize: number; + previewBytes: number; + contentType: string; + }): Promise; + + details(params: { bucket: string; key: string }): Promise; + + directoryMetadata(params: { bucket: string; prefix: string }): Promise; + + directorySize(params: { bucket: string; prefix: string }): 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 copy({ bucket, sourceKeys, destinationPrefix, progress, jobId, signal, callbacks }) { + return copyMoveRequest(fetch_, '/api/storage/copy', { + bucket, + sourceKeys, + destinationPrefix, + progress, + jobId, + signal, + callbacks + }); + }, + + async move({ + bucket, + sourceKeys, + destinationPrefix, + destinationKey, + progress, + jobId, + signal, + callbacks + }) { + return copyMoveRequest(fetch_, '/api/storage/move', { + bucket, + sourceKeys, + destinationPrefix, + destinationKey, + progress, + jobId, + signal, + callbacks + }); + }, + + 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) { + const res = await fetch_(`/api/storage/copy/job/${jobId}`); + return (await res.json()) as JobStatus; + }, + + async checkObjectExists({ bucket, key }) { + const params = new URLSearchParams({ bucket, key }); + try { + const res = await fetch_(`/api/storage/download?${params}`, { + method: 'HEAD' + }); + return res.ok; + } catch { + return false; + } + }, + + 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, originalSize, previewBytes, contentType }) { + const params = new URLSearchParams({ + bucket, + key, + originalSize: String(originalSize), + previewBytes: String(previewBytes), + contentType + }); + await fetch_(`/api/storage/save-text?${params}`, { + method: 'POST', + body + }); + }, + + 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 }) { + const params = new URLSearchParams({ bucket, prefix }); + return fetch_(`/api/storage/directory-size?${params}`); + }, + + 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 ──────────────────────────────────────────────────────────────── + +/** + * Shared implementation for copy and move requests. + * When `progress=true`, the response is an NDJSON stream. + */ +async function copyMoveRequest( + fetch_: (path: string, init?: RequestInit) => Promise, + endpoint: string, + params: { + bucket: string; + sourceKeys: string[]; + destinationPrefix: string; + destinationKey?: string; + progress?: boolean; + jobId?: string; + signal?: AbortSignal; + callbacks?: NdjsonStreamCallbacks; + } +): Promise { + const urlParams = new URLSearchParams({ bucket: params.bucket }); + if (params.progress) { + urlParams.set('progress', 'true'); + } + + const res = await fetch_(`${endpoint}?${urlParams}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceKeys: params.sourceKeys, + destinationPrefix: params.destinationPrefix, + destinationKey: params.destinationKey, + jobId: params.jobId + }), + signal: params.signal + }); + + if (params.progress) { + const streamResult = await readNdjsonStream(res.body, params.callbacks); + return { + results: streamResult.results, + failed: streamResult.failed.length + }; + } + + const data = (await res.json()) as { + results?: Array<{ sourceKey: string; destKey: string }>; + moved?: Array<{ sourceKey: string; destKey: string }>; + failed?: Array; + }; + const results = data.results ?? data.moved ?? []; + return { results, failed: data.failed?.length ?? 0 }; +} 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.svelte.ts b/src/lib/storage/clipboard.svelte.ts new file mode 100644 index 00000000..7dac9093 --- /dev/null +++ b/src/lib/storage/clipboard.svelte.ts @@ -0,0 +1,989 @@ +import { SvelteMap } from 'svelte/reactivity'; +import { tick } from 'svelte'; +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 } from '$lib/storage/utils.js'; +import { ActionError, 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'; + +// ── Internal types ────────────────────────────────────────────────────────── + +type PendingPasteOp = { + type: 'paste'; + keys: string[]; + sourceBucket: string; + destPrefix: string; + wasCut: boolean; + fileSizes: Record; + sourcePrefix: string; + totalBytes: number; +}; + +type PendingMoveOp = { + type: 'move'; + keys: string[]; + destPrefix: string; + items: Array<{ key: string; name: string; isDirectory: boolean; size?: number }>; + sourcePrefix: string | null; + totalBytes: number; +}; + +type PendingConflictOp = PendingPasteOp | PendingMoveOp | null; + +// ── Callbacks interface ────────────────────────────────────────────────────── + +export interface ClipboardStateCallbacks { + getBucket: () => string; + getPrefix: () => string; + 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 { + // ── Reactive state ─────────────────────────────────────────────────────── + + /** Current clipboard content (null when empty). */ + clipboard = $state(null); + + /** Pending conflict operation awaiting user resolution. */ + private _pendingConflictOp = $state(null); + + /** Source prefix for the current pending move operation. */ + private _pendingSourcePrefix: string | null = null; + + /** Pending move operation (before user confirmation). */ + private _pendingMove: { + keys: string[]; + destPrefix: string; + items: Array<{ key: string; name: string; isDirectory: boolean; size?: number }>; + sourcePrefix: string | null; + } | null = null; + + // ── Dependencies ────────────────────────────────────────────────────────── + + 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; + } + + // ── 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; + const fileSizes: Record = {}; + for (const obj of objects.objects) { + if (selectedKeys.includes(obj.key) && !obj.isDirectory) { + fileSizes[obj.key] = obj.size; + } + } + this.clipboard = { + action: 'cut', + keys: selectedKeys, + sourceBucket: bucket, + sourcePrefix: prefix, + fileSizes + }; + 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; + const fileSizes: Record = {}; + for (const obj of objects.objects) { + if (selectedKeys.includes(obj.key) && !obj.isDirectory) { + fileSizes[obj.key] = obj.size; + } + } + this.clipboard = { + action: 'copy', + keys: selectedKeys, + sourceBucket: bucket, + sourcePrefix: prefix, + fileSizes + }; + addToast('info', m.storage_action_copy_success({ count: selectedKeys.length })); + } + + // ── Paste ────────────────────────────────────────────────────────────────── + + /** + * Execute a paste operation into the given destination prefix. + * Caller should check storagePasteEnabled and clipboard non-empty first. + */ + async paste(destPrefix: string): Promise { + const bucket = this._callbacks.getBucket(); + const pasteClipboard = this.clipboard; + if (!pasteClipboard) return; + + const wasCut = pasteClipboard.action === 'cut'; + const pasteKeys = [...pasteClipboard.keys]; + + // ── Check for name conflicts at destination ────────────────────── + const conflictEntries = await this._checkDestinationConflicts(pasteKeys, destPrefix); + const hasConflicts = conflictEntries.some((e) => e.conflict); + if (hasConflicts) { + const totalBytes = pasteKeys.reduce((sum, k) => sum + (pasteClipboard.fileSizes[k] ?? 0), 0); + this._pendingConflictOp = { + type: 'paste', + keys: pasteKeys, + sourceBucket: pasteClipboard.sourceBucket, + destPrefix, + wasCut, + fileSizes: pasteClipboard.fileSizes, + sourcePrefix: pasteClipboard.sourcePrefix, + totalBytes + }; + this._callbacks.openModal('resolve-conflicts', { + entries: conflictEntries, + bucket, + destPrefix, + confirmLabel: m.storage_action_paste() + }); + return; + } + + const opId = crypto.randomUUID(); + const abortController = new AbortController(); + const sourceNames = pasteKeys.map((k) => keyToName(k)); + const isSinglePaste = sourceNames.length === 1; + const pasteLabel = isSinglePaste + ? `${m.storage_operation_paste_one({ count: 1 })}: ${sourceNames[0]}` + : `${m.storage_operation_paste_other({ count: sourceNames.length })}: ${sourceNames[0]} + ${sourceNames.length - 1} more`; + + const totalBytes = pasteKeys.reduce((sum, k) => sum + (pasteClipboard.fileSizes[k] ?? 0), 0); + this._operations.startOp( + opId, + pasteLabel, + 'paste', + pasteKeys.length, + abortController, + `${bucket}/${destPrefix}`, + sourceNames, + totalBytes + ); + try { + let completedBytes = 0; + const fileSizes = pasteClipboard?.fileSizes ?? {}; + const pasteSourceNames = pasteKeys.map((k) => keyToName(k)); + const fileJobIdsAccum: string[] = []; + const { results, failed } = await this._performPasteSequential( + pasteKeys, + pasteClipboard.sourceBucket, + destPrefix, + wasCut, + abortController.signal, + (index, key) => { + // File-level progress: use the known file size from clipboard. + + completedBytes += fileSizes[key] ?? 0; + this._operations.updateOpProgress(opId, index, completedBytes, keyToName(key)); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (loaded, _total) => { + // Byte-level progress during a large file's streaming upload. + const prevFiles = + this._operations.operations.find((op) => op.id === opId)?.completedCount ?? 0; + let prevBytes = 0; + for (let j = 0; j < prevFiles && j < pasteKeys.length; j++) { + prevBytes += fileSizes[pasteKeys[j]] ?? 0; + } + this._operations.updateOpProgress( + opId, + prevFiles, + prevBytes + loaded, + + pasteSourceNames[prevFiles] ?? '' + ); + }, + (index, jobId) => { + fileJobIdsAccum[index] = jobId; + this._operations.updateOpJobIds(opId, [...fileJobIdsAccum]); + } + ); + await tick(); + if (results.length === 0) { + this._operations.finishOp(opId, 'error'); + addToast('error', m.storage_action_paste_error_source_not_found()); + return; + } + this._operations.finishOp(opId, failed > 0 ? 'error' : 'done'); + // Record destination files as recent visits + const newFileSizes: Record = {}; + for (const r of results) { + if (r.destKey.endsWith('/')) continue; + const size = pasteClipboard.fileSizes?.[r.sourceKey] ?? 0; + newFileSizes[r.destKey] = size; + this._callbacks.recordFileVisit(bucket, r.destKey, size); + } + if (failed > 0) { + addToast('warning', m.storage_action_paste_partial({ count: failed })); + } else { + addToast('success', m.storage_action_paste_success({ count: results.length })); + } + // After a cut paste (move), update clipboard keys to the destination + // keys so subsequent pastes copy from the newly created files. + if (wasCut && results.length > 0) { + const destKeys = results.map((r) => r.destKey); + // Invalidate source tabs so they refetch (items moved out) + if (pasteClipboard.sourcePrefix) { + this._callbacks.invalidateSourceTabs(pasteClipboard.sourcePrefix); + } + this.clipboard = { + action: 'copy', + keys: destKeys, + sourceBucket: bucket, + sourcePrefix: this._callbacks.getPrefix(), + fileSizes: newFileSizes + }; + } + this._callbacks.refresh(); + // Invalidate background tabs viewing the destination + this._callbacks.invalidateSourceTabs(destPrefix); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') { + this._operations.finishOp(opId, 'cancelled'); + return; + } + if (pageUnloading) { + // Page is unloading — server-side copies continue regardless. + return; + } + this._operations.finishOp(opId, 'error'); + addToast( + 'error', + err instanceof ActionError ? getActionErrorMessage(err) : m.storage_action_paste_error() + ); + } + } + + // ── Move (drag-and-drop) ─────────────────────────────────────────────────── + + /** Validate a drag-and-drop move and open the confirmation dialog. */ + performMove = (destPrefix: string, keys?: string[]): void => { + if (!storageMoveEnabled) return; + const objects = this._callbacks.getObjects(); + const moveKeys = keys ?? [...this._callbacks.getSelectedKeys()]; + if (moveKeys.length === 0) return; + + // Don't move items that are already directly inside destPrefix (no-op). + const isAlreadyThere = (key: string): boolean => { + if (key.endsWith('/')) return key === destPrefix; + const parentPrefix = key.substring(0, key.lastIndexOf('/') + 1); + return parentPrefix === destPrefix; + }; + if (moveKeys.every(isAlreadyThere)) return; + + // Don't move a folder into itself + for (const k of moveKeys) { + if (k.endsWith('/') && destPrefix.startsWith(k)) return; + } + + // Collect per-item metadata for the confirmation dialog + const items = moveKeys.map((key) => { + const obj = objects.objects.find((o) => o.key === key); + return { + key, + name: keyToName(key), + isDirectory: key.endsWith('/'), + size: obj && !obj.isDirectory ? obj.size : undefined + }; + }); + + const sourcePrefix = this._commonPrefix(moveKeys); + this._pendingMove = { keys: moveKeys, destPrefix, items, sourcePrefix }; + this._pendingSourcePrefix = sourcePrefix; + this._callbacks.openModal('confirm-move', { keys: moveKeys, destPrefix, items }); + }; + + confirmMove = async (): Promise => { + if (!this._pendingMove) return; + const { keys: moveKeys, destPrefix, items } = this._pendingMove; + this._pendingMove = null; + this._callbacks.closeModal(); + + const bucket = this._callbacks.getBucket(); + + // ── Check for name conflicts at destination ────────────────────────── + const conflictEntries = await this._checkDestinationConflicts(moveKeys, destPrefix); + const hasConflicts = conflictEntries.some((e) => e.conflict); + if (hasConflicts) { + const totalBytes = items.reduce((sum, item) => sum + (item.size ?? 0), 0); + this._pendingConflictOp = { + type: 'move', + keys: moveKeys, + destPrefix, + items, + sourcePrefix: this._pendingSourcePrefix, + totalBytes + }; + this._callbacks.openModal('resolve-conflicts', { + entries: conflictEntries, + bucket, + destPrefix, + confirmLabel: m.storage_action_move() + }); + return; + } + + const opId = crypto.randomUUID(); + const abortController = new AbortController(); + const sourceNames = moveKeys.map((k) => keyToName(k)); + const totalBytes = items.reduce((sum, item) => sum + (item.size ?? 0), 0); + const isSingleMove = sourceNames.length === 1; + const moveLabel = isSingleMove + ? `${m.storage_operation_move_one({ count: 1 })}: ${sourceNames[0]}` + : `${m.storage_operation_move_other({ count: sourceNames.length })}: ${sourceNames[0]} + ${sourceNames.length - 1} more`; + this._operations.startOp( + opId, + moveLabel, + 'move', + moveKeys.length, + abortController, + `${bucket}/${destPrefix}`, + sourceNames, + totalBytes + ); + + try { + const results: Array<{ sourceKey: string; destKey: string }> = []; + let failed = 0; + const fileJobIds: string[] = []; + + for (let i = 0; i < moveKeys.length; i++) { + if (abortController.signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + const sourceKey = moveKeys[i]; + const fileJobId = crypto.randomUUID(); + fileJobIds.push(fileJobId); + this._operations.updateOpJobIds(opId, [...fileJobIds]); + + try { + const result = await this._api.move({ + bucket, + sourceKeys: [sourceKey], + destinationPrefix: destPrefix, + progress: true, + jobId: fileJobId, + signal: abortController.signal, + callbacks: { + onProgress: (_sourceKey, _destKey, loaded) => { + const prevBytes = results.reduce((sum, r) => { + const item = items.find((it) => it.key === r.sourceKey); + return sum + (item?.size ?? 0); + }, 0); + this._operations.updateOpProgress( + opId, + i + 1, + prevBytes + loaded, + keyToName(sourceKey) + ); + }, + onComplete: (finalResults, finalFailed) => { + if (finalResults.length > 0) { + results.length = 0; + results.push(...finalResults); + } + if (finalFailed.length > 0) { + failed = finalFailed.length; + } + } + } + }); + results.push(...result.results); + failed += result.failed; + const completedBytes = results.reduce((sum, r) => { + const item = items.find((it) => it.key === r.sourceKey); + return sum + (item?.size ?? 0); + }, 0); + this._operations.updateOpProgress(opId, i + 1, completedBytes, keyToName(sourceKey)); + } catch { + failed++; + } + } + + await tick(); + this._operations.finishOp(opId, failed === 0 ? 'done' : 'error'); + + if (results.length > 0) { + addToast('success', m.storage_action_move_success({ count: results.length })); + const movedKeys = results.map((r) => r.sourceKey).filter((k) => !k.endsWith('/')); + if (movedKeys.length > 0) { + this._callbacks.removeFiles(bucket, movedKeys); + } + for (const r of results) { + if (!r.destKey.endsWith('/')) { + const item = items.find((it) => it.key === r.sourceKey); + if (item && item.size) { + this._callbacks.recordFileVisit(bucket, r.destKey, item.size); + } + } + } + } + if (failed > 0) { + addToast('warning', m.storage_action_move_partial({ count: failed })); + } + + this._callbacks.clearSelection(); + if (this._pendingSourcePrefix !== null) { + this._callbacks.invalidateSourceTabs(this._pendingSourcePrefix); + this._pendingSourcePrefix = null; + } + // Invalidate background tabs viewing the destination + this._callbacks.invalidateSourceTabs(destPrefix); + this._callbacks.refresh(); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') { + this._operations.finishOp(opId, 'cancelled'); + this._pendingSourcePrefix = null; + return; + } + if (pageUnloading) { + return; + } + this._operations.finishOp(opId, 'error'); + this._pendingSourcePrefix = null; + addToast('error', m.storage_action_move_error()); + } + }; + + cancelMove = (): void => { + this._pendingMove = null; + this._callbacks.closeModal(); + }; + + // ── Conflict resolution ──────────────────────────────────────────────────── + + confirmConflictResolution = async (resolvedEntries: ConflictEntry[]): Promise => { + const pending = this._pendingConflictOp; + if (!pending) return; + this._pendingConflictOp = null; + this._callbacks.closeModal(); + + if (pending.type === 'paste') { + await this._executePasteWithConflicts(pending, resolvedEntries); + } else { + await this._executeMoveWithConflicts(pending, resolvedEntries); + } + }; + + cancelConflictResolution = (): void => { + this._pendingConflictOp = null; + this._callbacks.closeModal(); + }; + + // ── Clipboard cleanup ────────────────────────────────────────────────────── + + /** + * Remove deleted keys from the clipboard after a delete operation. + * Called after performDelete in StorageState. + */ + removeDeletedKeys(bucket: string, keys: string[]): void { + if (this.clipboard && this.clipboard.sourceBucket === bucket) { + const remainingKeys = this.clipboard.keys.filter((k) => !keys.includes(k)); + if (remainingKeys.length !== this.clipboard.keys.length) { + if (remainingKeys.length === 0) { + this.clipboard = null; + } else { + const remainingSizes: Record = {}; + for (const k of remainingKeys) { + if (this.clipboard.fileSizes[k] !== undefined) { + remainingSizes[k] = this.clipboard.fileSizes[k]; + } + } + this.clipboard = { ...this.clipboard, keys: remainingKeys, fileSizes: remainingSizes }; + } + } + } + } + + // ── Private: Paste helpers ───────────────────────────────────────────────── + + private async _performPaste( + keys: string[], + _sourceBucket: string, + destPrefix: string, + deleteOriginals = false, + signal?: AbortSignal + ): Promise<{ + results: Array<{ sourceKey: string; destKey: string }>; + failed: number; + }> { + if (deleteOriginals) { + return this._api.move({ + bucket: this._callbacks.getBucket(), + sourceKeys: keys, + destinationPrefix: destPrefix, + signal + }); + } + return this._api.copy({ + bucket: this._callbacks.getBucket(), + sourceKeys: keys, + destinationPrefix: destPrefix, + signal + }); + } + + /** + * Process paste/move keys one at a time, calling `onFileComplete` after + * each file so the caller can update byte-level progress. + */ + private async _performPasteSequential( + keys: string[], + _sourceBucket: string, + destPrefix: string, + deleteOriginals = false, + signal?: AbortSignal, + onFileComplete?: (index: number, key: string) => void, + onFileProgress?: (loaded: number, total: number) => void, + onFileJobId?: (index: number, jobId: string) => void + ): Promise<{ + results: Array<{ sourceKey: string; destKey: string }>; + failed: number; + fileJobIds: string[]; + }> { + const results: Array<{ sourceKey: string; destKey: string }> = []; + let failed = 0; + const fileJobIds: string[] = []; + const moveFn = deleteOriginals + ? (params: Parameters[0]) => this._api.move(params) + : (params: Parameters[0]) => this._api.copy(params); + + for (let i = 0; i < keys.length; i++) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + + const sourceKey = keys[i]; + const fileJobId = crypto.randomUUID(); + fileJobIds.push(fileJobId); + onFileJobId?.(i, fileJobId); + + try { + const result = await moveFn({ + bucket: this._callbacks.getBucket(), + sourceKeys: [sourceKey], + destinationPrefix: destPrefix, + progress: true, + jobId: fileJobId, + signal, + callbacks: { + onProgress: onFileProgress + ? (_sourceKey, _destKey, loaded, total) => onFileProgress(loaded, total) + : undefined + } + }); + results.push(...result.results); + failed += result.failed; + } catch { + failed++; + } + onFileComplete?.(i + 1, sourceKey); + } + + return { results, failed, fileJobIds }; + } + + // ── Private: Conflict resolution ────────────────────────────────────────── + + /** + * Check which destination keys already exist, returning ConflictEntry[] + * with conflict=true for existing keys. + */ + private async _checkDestinationConflicts( + keys: string[], + destPrefix: string + ): Promise { + const bucket = this._callbacks.getBucket(); + const results: ConflictEntry[] = []; + + for (const key of keys) { + const origName = keyToName(key); + const destKey = destPrefix + origName; + let conflict = false; + try { + conflict = await this._api.checkObjectExists({ bucket, key: destKey }); + } catch { + // If the check fails, assume no conflict and proceed + } + results.push({ + id: crypto.randomUUID(), + originalName: origName, + conflict, + resolution: conflict ? null : 'replace', + customName: origName, + renameState: 'idle', + sourceKey: key + }); + } + + return results; + } + + /** + * Delete conflicting destination files before copy/move to prevent + * the server from auto-renaming them with " (2)" suffix. + */ + private async _deleteConflictingDests( + destPrefix: string, + resolvedEntries: ConflictEntry[] + ): Promise { + const bucket = this._callbacks.getBucket(); + const keysToDelete: string[] = []; + for (const entry of resolvedEntries) { + if (entry.resolution === 'skip' || entry.resolution === 'rename') continue; + if (entry.conflict) { + keysToDelete.push(destPrefix + entry.originalName); + } + } + + if (keysToDelete.length === 0) return; + + try { + await this._api.delete({ bucket, keys: keysToDelete }); + } catch { + // Best-effort - if deletion fails, the server may auto-rename + } + } + + /** + * Execute a paste operation with conflict resolutions. + * Renamed entries are first pasted with their original name, then renamed. + */ + private async _executePasteWithConflicts( + pending: PendingPasteOp, + resolvedEntries: ConflictEntry[] + ): Promise { + const bucket = this._callbacks.getBucket(); + const resolvedMap = new SvelteMap( + resolvedEntries.map((e) => [e.sourceKey ?? e.originalName, e]) + ); + + // Split keys by resolution + const replaceKeys: string[] = []; + const renameKeys: Array<{ sourceKey: string; newName: string }> = []; + + for (const key of pending.keys) { + const entry = resolvedMap.get(key) ?? resolvedMap.get(keyToName(key)); + if (!entry || entry.resolution === 'skip') continue; + if (entry.resolution === 'rename') { + renameKeys.push({ sourceKey: key, newName: entry.customName.trim() }); + } + replaceKeys.push(key); + } + + if (replaceKeys.length === 0 && renameKeys.length === 0) return; + + const opId = crypto.randomUUID(); + const abortController = new AbortController(); + const sourceNames = pending.keys.map((k) => keyToName(k)); + const isSinglePaste = pending.keys.length === 1; + const pasteLabel = isSinglePaste + ? `${m.storage_operation_paste_one({ count: 1 })}: ${sourceNames[0]}` + : `${m.storage_operation_paste_other({ count: sourceNames.length })}: ${sourceNames[0]} + ${sourceNames.length - 1} more`; + + this._operations.startOp( + opId, + pasteLabel, + 'paste', + replaceKeys.length + renameKeys.length, + abortController, + `${bucket}/${pending.destPrefix}`, + sourceNames, + pending.totalBytes + ); + + await this._deleteConflictingDests(pending.destPrefix, resolvedEntries); + + try { + const { results, failed } = await this._performPasteSequential( + replaceKeys, + pending.sourceBucket, + pending.destPrefix, + pending.wasCut, + abortController.signal, + (index, key) => { + this._operations.updateOpProgress(opId, index, 0, keyToName(key)); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (loaded, _total) => { + const prevFiles = + this._operations.operations.find((op) => op.id === opId)?.completedCount ?? 0; + this._operations.updateOpProgress(opId, prevFiles, loaded, ''); + } + ); + + // Handle renamed files: rename the already-pasted destination file + const destKeyMap = new SvelteMap(results.map((r) => [r.sourceKey, r.destKey])); + let renameFailed = 0; + for (const rename of renameKeys) { + if (abortController.signal.aborted) break; + const destKey = destKeyMap.get(rename.sourceKey); + if (!destKey) { + renameFailed++; + continue; + } + const newKey = pending.destPrefix + rename.newName; + try { + await this._api.move({ + bucket, + sourceKeys: [destKey], + destinationPrefix: '', + destinationKey: newKey + }); + this._operations.updateOpProgress( + opId, + replaceKeys.length + renameKeys.indexOf(rename) + 1, + 0, + rename.newName + ); + } catch { + renameFailed++; + } + } + + await tick(); + const totalFailed = failed + renameFailed; + this._operations.finishOp(opId, totalFailed > 0 ? 'error' : 'done'); + + if (totalFailed > 0) { + addToast('warning', m.storage_action_paste_partial({ count: totalFailed })); + } else { + addToast( + 'success', + m.storage_action_paste_success({ count: replaceKeys.length + renameKeys.length }) + ); + } + this._callbacks.refresh(); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') { + this._operations.finishOp(opId, 'cancelled'); + return; + } + if (pageUnloading) { + return; + } + this._operations.finishOp(opId, 'error'); + addToast('error', m.storage_action_paste_error()); + } + } + + /** + * Execute a move operation with conflict resolutions. + * For "replace" entries: delete the conflicting destination first, then move. + * For "rename" entries: rename the source at its current location first, + * then move the renamed source to the destination — the file never appears + * at the original name in the destination. + */ + private async _executeMoveWithConflicts( + pending: PendingMoveOp, + resolvedEntries: ConflictEntry[] + ): Promise { + const bucket = this._callbacks.getBucket(); + const resolvedMap = new SvelteMap( + resolvedEntries.map((e) => [e.sourceKey ?? e.originalName, e]) + ); + + // Split keys by resolution — rename entries are NOT added to replaceKeys here. + // They will be renamed at source first, then added to replaceKeys before the move loop. + const replaceKeys: string[] = []; + const renameKeys: Array<{ sourceKey: string; newName: string }> = []; + + for (const key of pending.keys) { + const entry = resolvedMap.get(key) ?? resolvedMap.get(keyToName(key)); + if (!entry || entry.resolution === 'skip') continue; + if (entry.resolution === 'rename') { + renameKeys.push({ sourceKey: key, newName: entry.customName.trim() }); + } else { + replaceKeys.push(key); + } + } + + if (replaceKeys.length === 0 && renameKeys.length === 0) return; + + const opId = crypto.randomUUID(); + const abortController = new AbortController(); + const sourceNames = pending.keys.map((k) => keyToName(k)); + const totalCount = replaceKeys.length + renameKeys.length; + const isSingleMove = totalCount === 1; + const moveLabel = isSingleMove + ? `${m.storage_operation_move_one({ count: 1 })}: ${sourceNames[0]}` + : `${m.storage_operation_move_other({ count: sourceNames.length })}: ${sourceNames[0]} + ${sourceNames.length - 1} more`; + + this._operations.startOp( + opId, + moveLabel, + 'move', + totalCount, + abortController, + `${bucket}/${pending.destPrefix}`, + sourceNames, + pending.totalBytes + ); + + // Only delete conflicting destinations for "replace" entries + await this._deleteConflictingDests(pending.destPrefix, resolvedEntries); + + try { + const results: Array<{ sourceKey: string; destKey: string }> = []; + let renameFailed = 0; + let failed = 0; + + // ── Pre-processing: rename rename entries at source before moving ───── + for (const rename of renameKeys) { + if (abortController.signal.aborted) break; + + const parts = rename.sourceKey.split('/'); + parts.pop(); + const parentPrefix = parts.length > 0 ? parts.join('/') + '/' : ''; + const renamedSourceKey = + parentPrefix + rename.newName + (rename.sourceKey.endsWith('/') ? '/' : ''); + + try { + await this._api.move({ + bucket, + sourceKeys: [rename.sourceKey], + destinationPrefix: '', + destinationKey: renamedSourceKey + }); + replaceKeys.push(renamedSourceKey); + } catch { + renameFailed++; + } + } + + failed = renameFailed; + + // ── Move all entries ──────────────────────────────────────────────────── + + for (const key of replaceKeys) { + if (abortController.signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + try { + const result = await this._api.move({ + bucket, + sourceKeys: [key], + destinationPrefix: pending.destPrefix, + progress: true, + signal: abortController.signal, + callbacks: { + onComplete: (finalResults, finalFailed) => { + if (finalResults.length > 0) { + results.length = 0; + results.push(...finalResults); + } + if (finalFailed.length > 0) { + failed = finalFailed.length; + } + } + } + }); + results.push(...result.results); + failed += result.failed; + } catch { + failed++; + } + this._operations.updateOpProgress(opId, results.length, 0, keyToName(key)); + } + + await tick(); + const totalFailed = renameFailed + failed; + this._operations.finishOp(opId, totalFailed === 0 ? 'done' : 'error'); + + if (results.length > 0) { + addToast('success', m.storage_action_move_success({ count: results.length })); + const movedKeys = results.map((r) => r.sourceKey).filter((k) => !k.endsWith('/')); + if (movedKeys.length > 0) { + this._callbacks.removeFiles(bucket, movedKeys); + } + for (const r of results) { + if (!r.destKey.endsWith('/')) { + const item = pending.items.find((it) => it.key === r.sourceKey); + if (item && item.size) { + this._callbacks.recordFileVisit(bucket, r.destKey, item.size); + } + } + } + } + if (totalFailed > 0) { + addToast('warning', m.storage_action_move_partial({ count: totalFailed })); + } + + this._callbacks.clearSelection(); + if (pending.sourcePrefix !== null) { + this._callbacks.invalidateSourceTabs(pending.sourcePrefix); + } + // Invalidate background tabs viewing the destination + this._callbacks.invalidateSourceTabs(pending.destPrefix); + this._callbacks.refresh(); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') { + this._operations.finishOp(opId, 'cancelled'); + return; + } + if (pageUnloading) { + return; + } + this._operations.finishOp(opId, 'error'); + addToast('error', m.storage_action_move_error()); + } + } + + // ── Private: Helpers ─────────────────────────────────────────────────────── + + /** Returns the longest common directory prefix of the given keys. */ + private _commonPrefix(keys: string[]): string { + if (keys.length === 0) return ''; + const parts = keys[0].split('/'); + parts.pop(); // remove filename + let prefix = parts.join('/') ? parts.join('/') + '/' : ''; + for (let i = 1; i < keys.length; i++) { + while (prefix && !keys[i].startsWith(prefix)) { + const idx = prefix.lastIndexOf('/', prefix.length - 2); + prefix = idx >= 0 ? prefix.substring(0, idx + 1) : ''; + } + } + return prefix; + } +} 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..a45be5a8 --- /dev/null +++ b/src/lib/storage/connection-store.svelte.spec.ts @@ -0,0 +1,68 @@ +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('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..8c9da142 --- /dev/null +++ b/src/lib/storage/connection-store.svelte.ts @@ -0,0 +1,40 @@ +/** + * 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 { + return connection.endpoint.split(':')[0] ?? ''; + } +} + +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/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..9132607d --- /dev/null +++ b/src/lib/storage/details-types.ts @@ -0,0 +1,106 @@ +/** 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; +} + +/** 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; + tree: TreemapNode; + childrenByDepth: Record; + maxDepth: number; + durationMs: number; +} + +/** Metadata about an S3 directory/folder (bucket ACL + optional directory marker object). */ +export interface DirectoryMetadata { + bucketOwner: string; + bucketGrants: Array<{ grantee: string; permission: string }>; + 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; +} + +/** 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 configuration details. */ +export interface BucketDetails { + name: string; + versioning: 'Enabled' | 'Suspended' | 'Disabled'; + lifecycleRules: LifecycleRule[]; + tags: Record; + acl: BucketAcl; +} 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.ts b/src/lib/storage/download.ts index 61ea9460..952c0106 100644 --- a/src/lib/storage/download.ts +++ b/src/lib/storage/download.ts @@ -2,10 +2,8 @@ * 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. + * 1. Fetch the object via `storageFetch` which injects the connection header + * and maps HTTP errors to `StorageError`. * 3. On success: create a Blob URL and trigger a native browser download via a * programmatic anchor click. * @@ -15,60 +13,28 @@ * proportional browser memory. */ -import { STORAGE_CONNECTION_HEADER } from '$lib/storage/connection-storage.js'; +import { createStorageFetch } from '$lib/storage/storage-fetch.js'; +import type { StorageErrorCode } from '$lib/storage/errors.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'; - } -} - -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'; -} +export type DownloadErrorCode = StorageErrorCode; /** * Download a single S3 object. * - * Fetches the object with the connection config header, buffers it as a Blob, + * Fetches the object with the connection ID 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. + * @throws {StorageError} when the server returns a non-2xx response. */ export async function downloadObject( bucket: string, key: string, - connectionHeader: string + connectionId: string ): Promise { - const url = buildDownloadUrl(bucket, key); - - const response = await fetch(url, { - headers: { [STORAGE_CONNECTION_HEADER]: connectionHeader } - }); + const fetch_ = createStorageFetch(() => connectionId); + const url = `/api/storage/download?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`; - if (!response.ok) { - const code = mapStatusToCode(response.status); - throw new DownloadError(code, `Download failed with status ${response.status}`); - } + const response = await fetch_(url); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); diff --git a/src/lib/storage/drag-handlers.ts b/src/lib/storage/drag-handlers.ts new file mode 100644 index 00000000..4cf2ef76 --- /dev/null +++ b/src/lib/storage/drag-handlers.ts @@ -0,0 +1,43 @@ +import { SvelteSet } from 'svelte/reactivity'; +import { storageCutCopyEnabled, storageMoveEnabled } from '$lib/client/feature-flags.js'; + +/** + * 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; archive: { isInArchive: boolean } } +): void { + if (!storageCutCopyEnabled || state.archive.isInArchive) return; + if (!state.selectedKeys.has(itemKey)) { + state.selectedKeys = new SvelteSet([itemKey]); + } + e.dataTransfer?.setData('application/x-storage-keys', JSON.stringify([...state.selectedKeys])); + e.dataTransfer!.effectAllowed = 'move'; +} + +/** + * Parse storage keys from a drop event's data transfer. + * Returns null if the data is missing or invalid. + */ +export function parseStorageDropKeys(e: DragEvent): string[] | null { + const raw = e.dataTransfer?.getData('application/x-storage-keys'); + if (!raw) return null; + try { + return JSON.parse(raw) as string[]; + } 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..25a626b6 --- /dev/null +++ b/src/lib/storage/errors.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + StorageError, + ActionError, + 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('ActionError', () => { + it('extends StorageError with ActionError name', () => { + const err = new ActionError('access_denied', 'No access'); + expect(err).toBeInstanceOf(StorageError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('ActionError'); + expect(err.code).toBe('access_denied'); + expect(err.message).toBe('No access'); + }); +}); + +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..275deca1 100644 --- a/src/lib/storage/errors.ts +++ b/src/lib/storage/errors.ts @@ -1,20 +1,41 @@ 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' + | 'unknown'; + +export type ActionErrorCode = StorageErrorCode; + +export class StorageError extends Error { constructor( public readonly code: string, message: string ) { super(message); + this.name = 'StorageError'; + } +} + +// ── ActionError (backward-compatible alias) ────────────────────────────────── + +export class ActionError extends StorageError { + constructor(code: string, message: string) { + super(code, message); this.name = 'ActionError'; } } // ── 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 +53,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/ndjson-stream.spec.ts b/src/lib/storage/ndjson-stream.spec.ts new file mode 100644 index 00000000..3b1b9f87 --- /dev/null +++ b/src/lib/storage/ndjson-stream.spec.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi } from 'vitest'; +import { readNdjsonStream } from './ndjson-stream.js'; + +function createStream(lines: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const line of lines) { + controller.enqueue(encoder.encode(line + '\n')); + } + controller.close(); + } + }); +} + +describe('readNdjsonStream', () => { + it('returns empty results for null stream', async () => { + const result = await readNdjsonStream(null); + expect(result).toEqual({ results: [], failed: [] }); + }); + + it('parses progress events and calls onProgress', async () => { + const onProgress = vi.fn(); + const stream = createStream([ + JSON.stringify({ + type: 'progress', + sourceKey: 'a.txt', + destKey: 'b.txt', + loaded: 50, + total: 100 + }) + ]); + + const result = await readNdjsonStream(stream, { onProgress }); + + expect(onProgress).toHaveBeenCalledWith('a.txt', 'b.txt', 50, 100); + expect(result.results).toEqual([]); + expect(result.failed).toEqual([]); + }); + + it('parses done events and accumulates results', async () => { + const onDone = vi.fn(); + const stream = createStream([ + JSON.stringify({ type: 'done', sourceKey: 'a.txt', destKey: 'b.txt' }), + JSON.stringify({ type: 'done', sourceKey: 'c.txt', destKey: 'd.txt' }) + ]); + + const result = await readNdjsonStream(stream, { onDone }); + + expect(result.results).toEqual([ + { sourceKey: 'a.txt', destKey: 'b.txt' }, + { sourceKey: 'c.txt', destKey: 'd.txt' } + ]); + expect(onDone).toHaveBeenCalledTimes(2); + }); + + it('ignores done events missing sourceKey or destKey', async () => { + const stream = createStream([ + JSON.stringify({ type: 'done', sourceKey: '', destKey: 'b.txt' }) + ]); + + const result = await readNdjsonStream(stream); + + expect(result.results).toEqual([]); + }); + + it('parses failed events and accumulates failures', async () => { + const onFailed = vi.fn(); + const stream = createStream([ + JSON.stringify({ type: 'failed', sourceKey: 'a.txt', error: 'Access denied' }) + ]); + + const result = await readNdjsonStream(stream, { onFailed }); + + expect(result.failed).toEqual([{ sourceKey: 'a.txt', error: 'Access denied' }]); + expect(onFailed).toHaveBeenCalledWith('a.txt', 'Access denied'); + }); + + it('uses default error message when error field is missing', async () => { + const stream = createStream([JSON.stringify({ type: 'failed', sourceKey: 'a.txt' })]); + + const result = await readNdjsonStream(stream); + + expect(result.failed).toEqual([{ sourceKey: 'a.txt', error: 'Unknown error' }]); + }); + + it('ignores failed events missing sourceKey', async () => { + const stream = createStream([JSON.stringify({ type: 'failed', error: 'err' })]); + + const result = await readNdjsonStream(stream); + + expect(result.failed).toEqual([]); + }); + + it('handles complete event with results and failed', async () => { + const onComplete = vi.fn(); + const stream = createStream([ + JSON.stringify({ + type: 'complete', + results: [{ sourceKey: 'a.txt', destKey: 'b.txt' }], + failed: [{ sourceKey: 'c.txt', error: 'err' }] + }) + ]); + + const result = await readNdjsonStream(stream, { onComplete }); + + expect(result.results).toEqual([{ sourceKey: 'a.txt', destKey: 'b.txt' }]); + expect(result.failed).toEqual([{ sourceKey: 'c.txt', error: 'err' }]); + expect(onComplete).toHaveBeenCalledWith( + [{ sourceKey: 'a.txt', destKey: 'b.txt' }], + [{ sourceKey: 'c.txt', error: 'err' }] + ); + }); + + it('complete event replaces previously accumulated results', async () => { + const stream = createStream([ + JSON.stringify({ type: 'done', sourceKey: 'old.txt', destKey: 'old2.txt' }), + JSON.stringify({ type: 'failed', sourceKey: 'old3.txt', error: 'err' }), + JSON.stringify({ + type: 'complete', + results: [{ sourceKey: 'a.txt', destKey: 'b.txt' }], + failed: [{ sourceKey: 'c.txt', error: 'err2' }] + }) + ]); + + const result = await readNdjsonStream(stream); + + expect(result.results).toEqual([{ sourceKey: 'a.txt', destKey: 'b.txt' }]); + expect(result.failed).toEqual([{ sourceKey: 'c.txt', error: 'err2' }]); + }); + + it('complete event with moved key (backward compat)', async () => { + const stream = createStream([ + JSON.stringify({ + type: 'complete', + moved: [{ sourceKey: 'a.txt', destKey: 'b.txt' }], + failed: [] + }) + ]); + + const result = await readNdjsonStream(stream); + + expect(result.results).toEqual([{ sourceKey: 'a.txt', destKey: 'b.txt' }]); + }); + + it('calls onStatus with message', async () => { + const onStatus = vi.fn(); + const stream = createStream([JSON.stringify({ type: 'status', message: 'Processing...' })]); + + await readNdjsonStream(stream, { onStatus }); + + expect(onStatus).toHaveBeenCalledWith('Processing...'); + }); + + it('handles empty lines gracefully', async () => { + const stream = createStream([ + '', + ' ', + JSON.stringify({ type: 'done', sourceKey: 'a.txt', destKey: 'b.txt' }) + ]); + + const result = await readNdjsonStream(stream); + + expect(result.results).toEqual([{ sourceKey: 'a.txt', destKey: 'b.txt' }]); + }); + + it('handles chunked decoding across buffer boundaries', async () => { + const encoder = new TextEncoder(); + const data = JSON.stringify({ type: 'done', sourceKey: 'a.txt', destKey: 'b.txt' }) + '\n'; + const mid = Math.floor(data.length / 2); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(data.slice(0, mid))); + controller.enqueue(encoder.encode(data.slice(mid))); + controller.close(); + } + }); + + const result = await readNdjsonStream(stream); + + expect(result.results).toEqual([{ sourceKey: 'a.txt', destKey: 'b.txt' }]); + }); + + it('releases reader lock on completion', async () => { + const stream = createStream([ + JSON.stringify({ type: 'done', sourceKey: 'a.txt', destKey: 'b.txt' }) + ]); + + await readNdjsonStream(stream); + + expect(() => stream.getReader()).not.toThrow(); + }); +}); diff --git a/src/lib/storage/ndjson-stream.ts b/src/lib/storage/ndjson-stream.ts new file mode 100644 index 00000000..6eb598c4 --- /dev/null +++ b/src/lib/storage/ndjson-stream.ts @@ -0,0 +1,136 @@ +/** + * Shared NDJSON streaming reader for storage operation progress events. + * + * The server returns NDJSON (newline-delimited JSON) for copy/move operations + * when ?progress=true. Each line is a typed event: + * + * {"type":"progress","sourceKey":"...","destKey":"...","loaded":N,"total":N} + * {"type":"done","sourceKey":"...","destKey":"..."} + * {"type":"failed","sourceKey":"...","error":"..."} + * {"type":"complete","results":[...],"failed":[...]} + * {"type":"status","message":"..."} + * + * This module extracts the buffered-reader + parse + dispatch loop that was + * previously duplicated across three places in StorageState. + */ + +export interface NdjsonStreamEvent { + type: string; + sourceKey?: string; + destKey?: string; + loaded?: number; + total?: number; + error?: string; + results?: Array<{ sourceKey: string; destKey: string }>; + moved?: Array<{ sourceKey: string; destKey: string }>; + failed?: Array<{ sourceKey: string; error: string }>; + message?: string; +} + +export interface NdjsonStreamCallbacks { + onProgress?: (sourceKey: string, destKey: string, loaded: number, total: number) => void; + onDone?: (sourceKey: string, destKey: string) => void; + onFailed?: (sourceKey: string, error: string) => void; + onComplete?: ( + results: Array<{ sourceKey: string; destKey: string }>, + failed: Array<{ sourceKey: string; error: string }> + ) => void; + onStatus?: (message: string) => void; +} + +export interface NdjsonStreamResult { + results: Array<{ sourceKey: string; destKey: string }>; + failed: Array<{ sourceKey: string; error: string }>; +} + +/** + * Read an NDJSON stream from a fetch Response body, dispatching typed events + * to the provided callbacks. + * + * Returns the aggregated results and failures. The `complete` event (if present) + * replaces any results/failures accumulated from individual `done`/`failed` + * events, matching the server's convention of sending a final summary line. + */ +export async function readNdjsonStream( + stream: ReadableStream | null, + callbacks: NdjsonStreamCallbacks = {} +): Promise { + const results: Array<{ sourceKey: string; destKey: string }> = []; + const failed: Array<{ sourceKey: string; error: string }> = []; + + if (!stream) return { results, failed }; + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + 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 event = JSON.parse(line) as NdjsonStreamEvent; + + switch (event.type) { + case 'progress': + if (event.loaded !== undefined && event.total !== undefined) { + callbacks.onProgress?.( + event.sourceKey ?? '', + event.destKey ?? '', + event.loaded, + event.total + ); + } + break; + + case 'done': + if (event.sourceKey && event.destKey) { + results.push({ sourceKey: event.sourceKey, destKey: event.destKey }); + callbacks.onDone?.(event.sourceKey, event.destKey); + } + break; + + case 'failed': + if (event.sourceKey) { + failed.push({ sourceKey: event.sourceKey, error: event.error ?? 'Unknown error' }); + callbacks.onFailed?.(event.sourceKey, event.error ?? 'Unknown error'); + } + break; + + case 'complete': { + const finalResults = (event.results ?? event.moved) as + | Array<{ sourceKey: string; destKey: string }> + | undefined; + const finalFailed = event.failed as + | Array<{ sourceKey: string; error: string }> + | undefined; + if (finalResults) { + results.length = 0; + results.push(...finalResults); + } + if (finalFailed) { + failed.length = 0; + failed.push(...finalFailed); + } + callbacks.onComplete?.(finalResults ?? results, finalFailed ?? failed); + break; + } + + case 'status': + callbacks.onStatus?.(event.message ?? ''); + break; + } + } + } + } finally { + reader.releaseLock(); + } + + return { results, failed }; +} diff --git a/src/lib/storage/operations.svelte.spec.ts b/src/lib/storage/operations.svelte.spec.ts new file mode 100644 index 00000000..ac84b7d1 --- /dev/null +++ b/src/lib/storage/operations.svelte.spec.ts @@ -0,0 +1,751 @@ +vi.mock('$app/environment', () => ({ browser: true })); + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { OperationsState } from './operations.svelte.js'; +import type { StorageApi } from './api.js'; +import type { StorageOperation } from './types.js'; + +const OPERATIONS_HISTORY_KEY = 'storage_operations_history'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeMockApi(): StorageApi { + return { + pollJob: vi.fn().mockResolvedValue({ status: 'done' }), + list: vi.fn(), + copy: vi.fn(), + move: vi.fn(), + rename: vi.fn(), + delete: vi.fn(), + create: vi.fn(), + archiveExtract: vi.fn(), + archiveListing: 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 makeOpts( + overrides?: Partial +): import('./operations.svelte.js').OperationsStateOpts { + return { + getBucket: () => 'test-bucket', + getPrefix: () => '', + ...overrides + }; +} + +function makeOp(overrides?: Partial): StorageOperation { + return { + id: 'op-1', + label: 'Copy: file.txt', + status: 'running', + type: 'paste', + itemCount: 1, + completedCount: 0, + startedAt: Date.now(), + destPath: 'test-bucket/test/', + sourceNames: ['file.txt'], + totalBytes: 100, + completedBytes: 0, + ...overrides + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Group 1: localStorage persistence +// ────────────────────────────────────────────────────────────────────────────── + +describe('localStorage persistence', () => { + it('constructor reads running ops from localStorage and marks them interrupted', () => { + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([makeOp({ id: 'op-1', status: 'running' })]) + ); + + const state = new OperationsState(makeMockApi(), makeOpts()); + + expect(state.operations).toHaveLength(1); + expect(state.operations[0].id).toBe('op-1'); + expect(state.operations[0].status).toBe('interrupted'); + }); + + it('constructor handles malformed JSON gracefully', () => { + localStorage.setItem(OPERATIONS_HISTORY_KEY, 'not-valid-json'); + + const state = new OperationsState(makeMockApi(), makeOpts()); + + expect(state.operations).toEqual([]); + }); + + it('constructor handles non-array JSON gracefully', () => { + localStorage.setItem(OPERATIONS_HISTORY_KEY, '{"key": "value"}'); + + const state = new OperationsState(makeMockApi(), makeOpts()); + + expect(state.operations).toEqual([]); + }); + + it('constructor handles missing localStorage key gracefully', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + + expect(state.operations).toEqual([]); + }); + + it('startOp persists a running operation to localStorage', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + + state.startOp('op-1', 'Copy: file.txt', 'paste', 1); + + const stored = JSON.parse(localStorage.getItem(OPERATIONS_HISTORY_KEY)!) as StorageOperation[]; + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe('op-1'); + expect(stored[0].status).toBe('running'); + }); + + it('finishOp updates the operation status in localStorage', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Copy: file.txt', 'paste', 1); + + state.finishOp('op-1', 'done'); + + const stored = JSON.parse(localStorage.getItem(OPERATIONS_HISTORY_KEY)!) as StorageOperation[]; + expect(stored[0].status).toBe('done'); + expect(stored[0].completedAt).toBeGreaterThan(0); + }); + + it('updateOpJobIds persists fileJobIds to localStorage', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Copy: file.txt', 'paste', 3); + + state.updateOpJobIds('op-1', ['job-1', 'job-2']); + + const stored = JSON.parse(localStorage.getItem(OPERATIONS_HISTORY_KEY)!) as StorageOperation[]; + expect(stored[0].fileJobIds).toEqual(['job-1', 'job-2']); + }); + + it('clearOperationHistory removes non-running ops from localStorage but keeps running ones', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Op 1', 'paste', 1); + state.startOp('op-2', 'Op 2', 'move', 1); + state.finishOp('op-1', 'done'); + + state.clearOperationHistory(); + + const stored = JSON.parse(localStorage.getItem(OPERATIONS_HISTORY_KEY)!) as StorageOperation[]; + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe('op-2'); + expect(stored[0].status).toBe('running'); + }); + + it('keeps completed ops from previous pages in localStorage when saving new ones', () => { + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([makeOp({ id: 'legacy', status: 'done' })]) + ); + const state = new OperationsState(makeMockApi(), makeOpts()); + + state.startOp('op-1', 'New op', 'paste', 1); + + const stored = JSON.parse(localStorage.getItem(OPERATIONS_HISTORY_KEY)!) as StorageOperation[]; + expect(stored.some((o) => o.id === 'legacy')).toBe(true); + expect(stored.some((o) => o.id === 'op-1')).toBe(true); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Group 2: OperationsState lifecycle +// ────────────────────────────────────────────────────────────────────────────── + +describe('OperationsState lifecycle', () => { + it('startOp creates operation with correct label, type, itemCount, status=running, totalBytes', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + + state.startOp('op-1', 'Copy: file.txt', 'paste', 5, undefined, undefined, undefined, 1024); + + const op = state.operations[0]; + expect(op.id).toBe('op-1'); + expect(op.label).toBe('Copy: file.txt'); + expect(op.type).toBe('paste'); + expect(op.itemCount).toBe(5); + expect(op.status).toBe('running'); + expect(op.totalBytes).toBe(1024); + expect(op.completedCount).toBe(0); + expect(op.completedBytes).toBe(0); + expect(op.startedAt).toBeGreaterThan(0); + }); + + it('startOp stores abortController when provided', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + const controller = new AbortController(); + + state.startOp('op-1', 'Test', 'paste', 1, controller); + + expect(controller.signal.aborted).toBe(false); + state.cancelOp('op-1'); + expect(controller.signal.aborted).toBe(true); + }); + + it('updateOpProgress updates completedCount, completedBytes, currentFileName', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Copy: file.txt', 'paste', 3, undefined, undefined, undefined, 500); + + state.updateOpProgress('op-1', 2, 300, 'file2.txt'); + + const op = state.operations[0]; + expect(op.completedCount).toBe(2); + expect(op.completedBytes).toBe(300); + expect(op.currentFileName).toBe('file2.txt'); + }); + + it('updateOpProgress does NOT update operations with status cancelled', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 3, undefined, undefined, undefined, 500); + state.finishOp('op-1', 'cancelled'); + + state.updateOpProgress('op-1', 2, 200); + + const op = state.operations[0]; + expect(op.status).toBe('cancelled'); + expect(op.completedCount).toBe(0); + }); + + it('updateOpJobIds sets fileJobIds on the operation', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'move', 2); + + state.updateOpJobIds('op-1', ['job-a', 'job-b']); + + expect(state.operations[0].fileJobIds).toEqual(['job-a', 'job-b']); + }); + + it('finishOp with done sets status=done and completedAt', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); + + state.finishOp('op-1', 'done'); + + const op = state.operations[0]; + expect(op.status).toBe('done'); + expect(op.completedAt).toBeGreaterThan(0); + }); + + it('finishOp with error sets status=error and errorMessage', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); + + state.finishOp('op-1', 'error', 'Something went wrong'); + + const op = state.operations[0]; + expect(op.status).toBe('error'); + expect(op.errorMessage).toBe('Something went wrong'); + expect(op.completedAt).toBeGreaterThan(0); + }); + + it('finishOp with cancelled sets status=cancelled', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); + + state.finishOp('op-1', 'cancelled'); + + expect(state.operations[0].status).toBe('cancelled'); + }); + + it('finishOp does NOT override an already-cancelled operation', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); + state.finishOp('op-1', 'cancelled'); + + state.finishOp('op-1', 'done'); + + expect(state.operations[0].status).toBe('cancelled'); + }); + + it('finishOp does not throw when abort controller was never stored', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); // No abortController + + // Should not throw even though there's no controller to delete + expect(() => state.finishOp('op-1', 'done')).not.toThrow(); + expect(state.operations[0].status).toBe('done'); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Group 3: Reactive derived (hasRunningOps) +// ────────────────────────────────────────────────────────────────────────────── + +describe('hasRunningOps', () => { + it('is true when at least one operation has status=running', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); + + expect(state.hasRunningOps).toBe(true); + }); + + it('is false when all operations are done/error/cancelled', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); + state.finishOp('op-1', 'done'); + + expect(state.hasRunningOps).toBe(false); + }); + + it('transitions correctly when a running operation finishes', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); + + expect(state.hasRunningOps).toBe(true); + + state.finishOp('op-1', 'done'); + + expect(state.hasRunningOps).toBe(false); + }); + + it('is false when all persisted operations have status interrupted', () => { + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ id: 'op-1', status: 'running' }), + makeOp({ id: 'op-2', status: 'running' }) + ]) + ); + + const state = new OperationsState(makeMockApi(), makeOpts()); + + // Constructor marks them as 'interrupted', not 'running' + expect(state.hasRunningOps).toBe(false); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Group 4: cancelOp + clearOperationHistory +// ────────────────────────────────────────────────────────────────────────────── + +describe('cancelOp', () => { + it('aborts the abort controller and sets status=cancelled', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + const controller = new AbortController(); + state.startOp('op-1', 'Test', 'paste', 1, controller); + + state.cancelOp('op-1'); + + expect(controller.signal.aborted).toBe(true); + expect(state.operations[0].status).toBe('cancelled'); + }); + + it('stops polling timers', async () => { + vi.useFakeTimers(); + const api = makeMockApi(); + api.pollJob = vi.fn().mockResolvedValue({ + status: 'running', + progress: { completedBytes: 50 } + }); + + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([makeOp({ id: 'op-1', status: 'running', fileJobIds: ['job-1'] })]) + ); + + const state = new OperationsState(api, makeOpts()); + + // Flush microtasks so the first poll completes (returns 'running') + await Promise.resolve(); + await Promise.resolve(); + + // The op should now be 'running' and a timer is scheduled + expect(state.operations[0].status).toBe('running'); + expect(api.pollJob).toHaveBeenCalledWith('job-1'); + + // Cancel — should clear the pending timer + state.cancelOp('op-1'); + + // Reset mock call tracking + vi.clearAllMocks(); + + // Advance well past the 2000ms interval + vi.advanceTimersByTime(3000); + + expect(api.pollJob).not.toHaveBeenCalled(); + expect(state.operations[0].status).toBe('cancelled'); + + vi.useRealTimers(); + }); + + it('works when no abort controller exists', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Test', 'paste', 1); // No abortController + + expect(() => state.cancelOp('op-1')).not.toThrow(); + expect(state.operations[0].status).toBe('cancelled'); + }); + + it('works for unknown op id', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + + expect(() => state.cancelOp('nonexistent')).not.toThrow(); + }); +}); + +describe('clearOperationHistory', () => { + it('removes completed/failed/cancelled/interrupted ops, keeps only running', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-running', 'Running', 'paste', 1); + state.startOp('op-done', 'Done', 'move', 1); + state.finishOp('op-done', 'done'); + state.startOp('op-error', 'Error', 'rename', 1); + state.finishOp('op-error', 'error'); + state.startOp('op-cancelled', 'Cancelled', 'paste', 1); + state.finishOp('op-cancelled', 'cancelled'); + + state.clearOperationHistory(); + + expect(state.operations).toHaveLength(1); + expect(state.operations[0].id).toBe('op-running'); + expect(state.operations[0].status).toBe('running'); + }); + + it('keeps all operations when all are running', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'Running', 'paste', 1); + state.startOp('op-2', 'Also running', 'move', 1); + + state.clearOperationHistory(); + + expect(state.operations).toHaveLength(2); + }); + + it('works with empty operations array', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + + expect(() => state.clearOperationHistory()).not.toThrow(); + expect(state.operations).toEqual([]); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Group 5: Job polling (via reconcileInterruptedOps) +// ────────────────────────────────────────────────────────────────────────────── + +describe('job polling via reconcileInterruptedOps', () => { + it('marks interrupted ops as done when all jobs complete', async () => { + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ id: 'op-1', status: 'running', fileJobIds: ['job-1', 'job-2'], itemCount: 2 }) + ]) + ); + + const state = new OperationsState(makeMockApi(), makeOpts()); + + await vi.waitFor(() => { + const op = state.operations.find((o) => o.id === 'op-1'); + expect(op?.status).toBe('done'); + }); + + expect(state.operations[0].completedCount).toBe(2); + }); + + it('polls again when some jobs are still running', async () => { + const pollJob = vi + .fn() + .mockResolvedValue({ status: 'running', progress: { completedBytes: 50 } }); + + const api = makeMockApi(); + api.pollJob = pollJob; + + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ id: 'op-1', status: 'running', fileJobIds: ['job-1'], itemCount: 3 }) + ]) + ); + + const state = new OperationsState(api, makeOpts()); + + await vi.waitFor(() => { + const op = state.operations.find((o) => o.id === 'op-1'); + expect(op?.status).toBe('running'); + }); + + expect(pollJob).toHaveBeenCalledWith('job-1'); + expect(state.operations[0].completedBytes).toBe(50); + }); + + it('does not poll when interrupted ops have no fileJobIds', () => { + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([makeOp({ id: 'op-1', status: 'running' })]) + ); + + const api = makeMockApi(); + const state = new OperationsState(api, makeOpts()); + + expect(state.operations[0].status).toBe('interrupted'); + expect(api.pollJob).not.toHaveBeenCalled(); + }); + + it('does not poll when interrupted ops have empty fileJobIds', () => { + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([makeOp({ id: 'op-1', status: 'running', fileJobIds: [] })]) + ); + + const api = makeMockApi(); + const state = new OperationsState(api, makeOpts()); + + expect(state.operations[0].status).toBe('interrupted'); + expect(api.pollJob).not.toHaveBeenCalled(); + }); + + it('does not reconcile non-interrupted ops (e.g. done)', () => { + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([makeOp({ id: 'op-1', status: 'done', fileJobIds: ['job-1'] })]) + ); + + const api = makeMockApi(); + const state = new OperationsState(api, makeOpts()); + + expect(state.operations[0].status).toBe('done'); + expect(api.pollJob).not.toHaveBeenCalled(); + }); + + it('marks op as error when not all jobs completed', async () => { + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ id: 'op-1', status: 'running', fileJobIds: ['job-1'], itemCount: 5 }) + ]) + ); + + const state = new OperationsState(makeMockApi(), makeOpts()); + + await vi.waitFor(() => { + const op = state.operations.find((o) => o.id === 'op-1'); + expect(op?.status).toBe('error'); + }); + + expect(state.operations[0].completedCount).toBe(1); + }); + + it('handles pollJob failures gracefully (marks op as error)', async () => { + const api = makeMockApi(); + api.pollJob = vi.fn().mockRejectedValue(new Error('Network error')); + + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ id: 'op-1', status: 'running', fileJobIds: ['job-1'], itemCount: 3 }) + ]) + ); + + const state = new OperationsState(api, makeOpts()); + + await vi.waitFor(() => { + const op = state.operations.find((o) => o.id === 'op-1'); + expect(op?.status).toBe('error'); + }); + + expect(state.operations[0].completedCount).toBe(0); + expect(api.pollJob).toHaveBeenCalledWith('job-1'); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Group 6: Callbacks +// ────────────────────────────────────────────────────────────────────────────── + +describe('callbacks', () => { + it('onRefresh is called when destPath matches getBucket()/getPrefix()', async () => { + const onRefresh = vi.fn(); + const api = makeMockApi(); + api.pollJob = vi.fn().mockResolvedValue({ status: 'done' }); + + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ + id: 'op-1', + status: 'running', + fileJobIds: ['job-1'], + itemCount: 1, + destPath: 'test-bucket/' + }) + ]) + ); + + new OperationsState(api, makeOpts({ onRefresh })); + + await vi.waitFor(() => { + expect(onRefresh).toHaveBeenCalled(); + }); + }); + + it('onRefresh is NOT called when destPath does not match', async () => { + const onRefresh = vi.fn(); + const api = makeMockApi(); + api.pollJob = vi.fn().mockResolvedValue({ status: 'done' }); + + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ + id: 'op-1', + status: 'running', + fileJobIds: ['job-1'], + itemCount: 1, + destPath: 'other-bucket/' + }) + ]) + ); + + const state = new OperationsState(api, makeOpts({ onRefresh })); + + await vi.waitFor(() => { + const op = state.operations.find((o) => o.id === 'op-1'); + return op?.status === 'done'; + }); + + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('onInvalidateTabs is called when destPath is set', async () => { + const onInvalidateTabs = vi.fn(); + const api = makeMockApi(); + api.pollJob = vi.fn().mockResolvedValue({ status: 'done' }); + + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ + id: 'op-1', + status: 'running', + fileJobIds: ['job-1'], + itemCount: 1, + destPath: 'test-bucket/some/path/' + }) + ]) + ); + + new OperationsState(api, makeOpts({ onInvalidateTabs })); + + await vi.waitFor(() => { + expect(onInvalidateTabs).toHaveBeenCalledWith('some/path/'); + }); + }); + + it('onInvalidateTabs is NOT called when destPath is undefined', async () => { + const onInvalidateTabs = vi.fn(); + const api = makeMockApi(); + api.pollJob = vi.fn().mockResolvedValue({ status: 'done' }); + + localStorage.setItem( + OPERATIONS_HISTORY_KEY, + JSON.stringify([ + makeOp({ + id: 'op-1', + status: 'running', + fileJobIds: ['job-1'], + itemCount: 1, + destPath: undefined + }) + ]) + ); + + const state = new OperationsState(api, makeOpts({ onInvalidateTabs })); + + await vi.waitFor(() => { + const op = state.operations.find((o) => o.id === 'op-1'); + return op?.status === 'done'; + }); + + expect(onInvalidateTabs).not.toHaveBeenCalled(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Group 7: Multiple concurrent operations +// ────────────────────────────────────────────────────────────────────────────── + +describe('multiple concurrent operations', () => { + it('tracks two operations independently', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + + state.startOp('op-1', 'First op', 'paste', 3); + state.startOp('op-2', 'Second op', 'move', 5); + + expect(state.operations).toHaveLength(2); + expect(state.operations[0].id).toBe('op-1'); + expect(state.operations[1].id).toBe('op-2'); + }); + + it('updating progress on first op does not affect second', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'First', 'paste', 3, undefined, undefined, undefined, 300); + state.startOp('op-2', 'Second', 'move', 5, undefined, undefined, undefined, 500); + + state.updateOpProgress('op-1', 2, 200, 'file.txt'); + + const op1 = state.operations.find((o) => o.id === 'op-1')!; + const op2 = state.operations.find((o) => o.id === 'op-2')!; + + expect(op1.completedCount).toBe(2); + expect(op1.completedBytes).toBe(200); + expect(op2.completedCount).toBe(0); + expect(op2.completedBytes).toBe(0); + }); + + it('finishing first op does not affect the second', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'First', 'paste', 3); + state.startOp('op-2', 'Second', 'move', 5); + + state.finishOp('op-1', 'done'); + + const op1 = state.operations.find((o) => o.id === 'op-1')!; + const op2 = state.operations.find((o) => o.id === 'op-2')!; + + expect(op1.status).toBe('done'); + expect(op2.status).toBe('running'); + expect(state.hasRunningOps).toBe(true); + }); + + it('finishing both ops results in hasRunningOps being false', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + state.startOp('op-1', 'First', 'paste', 1); + state.startOp('op-2', 'Second', 'move', 1); + + state.finishOp('op-1', 'done'); + state.finishOp('op-2', 'error'); + + expect(state.hasRunningOps).toBe(false); + }); + + it('cancelling one op does not affect the other', () => { + const state = new OperationsState(makeMockApi(), makeOpts()); + const ctrl1 = new AbortController(); + const ctrl2 = new AbortController(); + state.startOp('op-1', 'First', 'paste', 1, ctrl1); + state.startOp('op-2', 'Second', 'move', 1, ctrl2); + + state.cancelOp('op-1'); + + expect(ctrl1.signal.aborted).toBe(true); + expect(ctrl2.signal.aborted).toBe(false); + expect(state.operations.find((o) => o.id === 'op-1')!.status).toBe('cancelled'); + expect(state.operations.find((o) => o.id === 'op-2')!.status).toBe('running'); + }); +}); diff --git a/src/lib/storage/operations.svelte.ts b/src/lib/storage/operations.svelte.ts new file mode 100644 index 00000000..44155bb9 --- /dev/null +++ b/src/lib/storage/operations.svelte.ts @@ -0,0 +1,239 @@ +import { SvelteMap } from 'svelte/reactivity'; +import { browser } from '$app/environment'; +import type { StorageOperation } from '$lib/storage/types.js'; +import type { StorageApi } from './api.js'; + +// ── Page-unloading flag ───────────────────────────────────────────────────── + +export let pageUnloading = false; +if (browser) { + window.addEventListener('beforeunload', () => { + pageUnloading = true; + }); +} + +// ── Operations history localStorage helpers ─────────────────────────────────── + +const OPERATIONS_HISTORY_KEY = 'storage_operations_history'; +const MAX_HISTORY_ENTRIES = 30; + +function loadPersistedOperations(): StorageOperation[] { + if (!browser) return []; + try { + const raw = localStorage.getItem(OPERATIONS_HISTORY_KEY); + if (!raw) return []; + const ops = JSON.parse(raw) as StorageOperation[]; + if (!Array.isArray(ops)) return []; + return ops.map((op) => + op.status === 'running' + ? { ...op, status: 'interrupted' as const, completedAt: op.completedAt ?? Date.now() } + : op + ); + } catch { + return []; + } +} + +function saveOperationsToStorage(ops: StorageOperation[]): void { + if (!browser) return; + try { + const history = ops.filter((op) => op.status !== 'running').slice(-MAX_HISTORY_ENTRIES); + const running = ops.filter((op) => op.status === 'running'); + const toSave = [...running, ...history].slice(-MAX_HISTORY_ENTRIES); + localStorage.setItem(OPERATIONS_HISTORY_KEY, JSON.stringify(toSave)); + } catch { + // Best effort. + } +} + +// ── OperationsState ───────────────────────────────────────────────────────── + +export interface OperationsStateOpts { + getBucket: () => string; + getPrefix: () => string; + onRefresh?: () => void; + onInvalidateTabs?: (prefix: string) => void; +} + +export class OperationsState { + operations = $state([]); + hasRunningOps = $derived(this.operations.some((op) => op.status === 'running')); + + private _abortControllers = new SvelteMap(); + private _pollTimers = new SvelteMap>(); + private _api: StorageApi; + private _getBucket: () => string; + private _getPrefix: () => string; + private _onRefresh?: () => void; + private _onInvalidateTabs?: (prefix: string) => void; + + constructor(api: StorageApi, opts: OperationsStateOpts) { + this._api = api; + this._getBucket = opts.getBucket; + this._getPrefix = opts.getPrefix; + this._onRefresh = opts.onRefresh; + this._onInvalidateTabs = opts.onInvalidateTabs; + this.operations = loadPersistedOperations(); + void this.reconcileInterruptedOps(); + } + + startOp( + id: string, + label: string, + type: StorageOperation['type'], + itemCount: number, + abortController?: AbortController, + destPath?: string, + sourceNames?: string[], + totalBytes = 0 + ): void { + this.operations = [ + ...this.operations, + { + id, + label, + status: 'running', + type, + itemCount, + completedCount: 0, + startedAt: Date.now(), + destPath, + sourceNames, + totalBytes, + completedBytes: 0 + } + ]; + if (abortController) { + this._abortControllers.set(id, abortController); + } + saveOperationsToStorage(this.operations); + } + + updateOpProgress( + id: string, + completedCount: number, + completedBytes: number, + currentFileName?: string + ): void { + this.operations = this.operations.map((op) => + op.id === id && op.status !== 'cancelled' + ? { ...op, completedCount, completedBytes, currentFileName } + : op + ); + } + + updateOpJobIds(id: string, fileJobIds: string[]): void { + this.operations = this.operations.map((op) => (op.id === id ? { ...op, fileJobIds } : op)); + saveOperationsToStorage(this.operations); + } + + finishOp(id: string, status: 'done' | 'error' | 'cancelled', errorMessage?: string): void { + this.operations = this.operations.map((op) => + op.id === id && op.status !== 'cancelled' + ? { ...op, status, errorMessage, completedAt: Date.now() } + : op + ); + this._abortControllers.delete(id); + saveOperationsToStorage(this.operations); + } + + cancelOp(id: string): void { + const controller = this._abortControllers.get(id); + if (controller) { + controller.abort(); + } + const timer = this._pollTimers.get(id); + if (timer) { + clearTimeout(timer); + this._pollTimers.delete(id); + } + this.finishOp(id, 'cancelled'); + } + + clearOperationHistory(): void { + this.operations = this.operations.filter((op) => op.status === 'running'); + saveOperationsToStorage(this.operations); + } + + private async reconcileInterruptedOps(): Promise { + const interrupted = this.operations.filter( + (op) => op.status === 'interrupted' && op.fileJobIds && op.fileJobIds.length > 0 + ); + if (interrupted.length === 0) return; + + for (const op of interrupted) { + void this._pollJobStatus(op); + } + } + + private async _pollJobStatus(op: StorageOperation): Promise { + if (this.operations.find((o) => o.id === op.id)?.status === 'cancelled') return; + + let completedCount = 0; + let completedBytes = 0; + let anyRunning = false; + + for (const jobId of op.fileJobIds!) { + try { + const job = await this._api.pollJob(jobId); + if (job.status === 'done') { + completedCount++; + } else if (job.status === 'running') { + anyRunning = true; + completedBytes += job.progress?.completedBytes ?? 0; + } + } catch { + // Job may have expired + } + } + + if (anyRunning) { + this.operations = this.operations.map((o) => + o.id === op.id + ? { + ...o, + status: 'running' as const, + completedCount, + completedBytes, + completedAt: undefined + } + : o + ); + const timer = setTimeout(() => void this._pollJobStatus(op), 2000); + this._pollTimers.set(op.id, timer); + } else { + this.operations = this.operations.map((o) => + o.id === op.id + ? { + ...o, + status: (completedCount === op.itemCount ? 'done' : 'error') as 'done' | 'error', + completedCount, + completedBytes, + completedAt: Date.now() + } + : o + ); + saveOperationsToStorage(this.operations); + + const existing = this._pollTimers.get(op.id); + if (existing) { + clearTimeout(existing); + this._pollTimers.delete(op.id); + } + + const bucket = this._getBucket(); + const prefix = this._getPrefix(); + if (op.destPath && `${bucket}/${prefix}`.startsWith(op.destPath)) { + this._onRefresh?.(); + } + + if (op.destPath && this._onInvalidateTabs) { + const slashIdx = op.destPath.indexOf('/'); + if (slashIdx !== -1) { + const p = op.destPath.slice(slashIdx + 1); + this._onInvalidateTabs(p.endsWith('/') ? p : p + '/'); + } + } + } + } +} diff --git a/src/lib/storage/persistence.ts b/src/lib/storage/persistence.ts index d295fd7b..47afae5b 100644 --- a/src/lib/storage/persistence.ts +++ b/src/lib/storage/persistence.ts @@ -5,6 +5,7 @@ import { browser } from '$app/environment'; export const LS_PINS = 'pinned_storage_locations'; export const LS_RECENT_FILES = 'recent_storage_files'; export const LS_RECENT_LOCATIONS = 'recent_storage_locations'; +export const LS_TABS = 'storage_tabs'; // ── Utilities ──────────────────────────────────────────────────────────────── diff --git a/src/lib/storage/schemas.spec.ts b/src/lib/storage/schemas.spec.ts new file mode 100644 index 00000000..a4cf3508 --- /dev/null +++ b/src/lib/storage/schemas.spec.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from 'vitest'; +import { + StorageConnectionSchema, + EditStorageConnectionSchema, + ConnectionIdSchema +} from './schemas.js'; + +describe('StorageConnectionSchema', () => { + it('accepts a minimal valid connection', () => { + const result = StorageConnectionSchema.safeParse({ host: 's3.example.com' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.host).toBe('s3.example.com'); + expect(result.data.type).toBe('s3'); + expect(result.data.accessStyle).toBe('VirtualHosted'); + expect(result.data.region).toEqual({ name: 'us-east-1' }); + expect(result.data.credentials).toEqual({ accessKey: '', secretKey: '' }); + } + }); + + it('rejects missing host', () => { + const result = StorageConnectionSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects empty host', () => { + const result = StorageConnectionSchema.safeParse({ host: '' }); + expect(result.success).toBe(false); + }); + + it('strips scheme from host when URL is provided', () => { + const result = StorageConnectionSchema.safeParse({ host: 'https://s3.example.com:9000' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.host).toBe('s3.example.com'); + } + }); + + it('strips scheme and path from complex URL', () => { + const result = StorageConnectionSchema.safeParse({ + host: 'http://storage.example.com/some/path' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.host).toBe('storage.example.com'); + } + }); + + it('accepts port', () => { + const result = StorageConnectionSchema.safeParse({ host: 's3.example.com', port: 9000 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.port).toBe(9000); + } + }); + + it('rejects port out of range', () => { + const result = StorageConnectionSchema.safeParse({ host: 's3.example.com', port: 99999 }); + expect(result.success).toBe(false); + }); + + it('accepts empty string port as undefined', () => { + const result = StorageConnectionSchema.safeParse({ host: 's3.example.com', port: '' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.port).toBeUndefined(); + } + }); + + it('accepts tls verification', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + tls: { verification: 'None' } + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tls).toEqual({ verification: 'None' }); + } + }); + + it('rejects tls with invalid verification value', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + tls: { verification: 'Invalid' } + }); + expect(result.success).toBe(false); + }); + + it('accepts accessStyle', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + accessStyle: 'Path' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.accessStyle).toBe('Path'); + } + }); + + it('accepts region override', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + region: { name: 'eu-west-1' } + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.region.name).toBe('eu-west-1'); + } + }); + + it('rejects empty region name', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + region: { name: '' } + }); + expect(result.success).toBe(false); + }); + + it('accepts full credentials', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + credentials: { accessKey: 'AKID', secretKey: 'secret' } + }); + expect(result.success).toBe(true); + }); + + it('rejects accessKey without secretKey', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + credentials: { accessKey: 'AKID', secretKey: '' } + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.path.includes('secretKey'))).toBe(true); + } + }); + + it('rejects secretKey without accessKey', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + credentials: { accessKey: '', secretKey: 'secret' } + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.path.includes('accessKey'))).toBe(true); + } + }); + + it('accepts uuid id', () => { + const result = StorageConnectionSchema.safeParse({ + id: '550e8400-e29b-41d4-a716-446655440000', + host: 's3.example.com' + }); + expect(result.success).toBe(true); + }); + + it('rejects non-uuid id', () => { + const result = StorageConnectionSchema.safeParse({ + id: 'not-a-uuid', + host: 's3.example.com' + }); + expect(result.success).toBe(false); + }); + + it('accepts optional name', () => { + const result = StorageConnectionSchema.safeParse({ + host: 's3.example.com', + name: 'My Connection' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('My Connection'); + } + }); +}); + +describe('EditStorageConnectionSchema', () => { + it('accepts accessKey without secretKey', () => { + const result = EditStorageConnectionSchema.safeParse({ + host: 's3.example.com', + credentials: { accessKey: 'AKID', secretKey: '' } + }); + expect(result.success).toBe(true); + }); + + it('still rejects secretKey without accessKey', () => { + const result = EditStorageConnectionSchema.safeParse({ + host: 's3.example.com', + credentials: { accessKey: '', secretKey: 'secret' } + }); + expect(result.success).toBe(false); + }); + + it('accepts both keys as before', () => { + const result = EditStorageConnectionSchema.safeParse({ + host: 's3.example.com', + credentials: { accessKey: 'AKID', secretKey: 'secret' } + }); + expect(result.success).toBe(true); + }); +}); + +describe('ConnectionIdSchema', () => { + it('accepts a valid UUID', () => { + const result = ConnectionIdSchema.safeParse({ + connectionId: '550e8400-e29b-41d4-a716-446655440000' + }); + expect(result.success).toBe(true); + }); + + it('rejects non-UUID string', () => { + const result = ConnectionIdSchema.safeParse({ connectionId: 'not-a-uuid' }); + expect(result.success).toBe(false); + }); + + it('rejects missing connectionId', () => { + const result = ConnectionIdSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/storage/schemas.ts b/src/lib/storage/schemas.ts index 5a51d14d..4c9deddd 100644 --- a/src/lib/storage/schemas.ts +++ b/src/lib/storage/schemas.ts @@ -67,3 +67,7 @@ export const EditStorageConnectionSchema = baseStorageConnectionObject.superRefi }); } }); + +export const ConnectionIdSchema = z.object({ + connectionId: z.string().uuid() +}); diff --git a/src/lib/storage/state.svelte.spec.ts b/src/lib/storage/state.svelte.spec.ts new file mode 100644 index 00000000..e9ac07b7 --- /dev/null +++ b/src/lib/storage/state.svelte.spec.ts @@ -0,0 +1,797 @@ +vi.mock('$lib/client/feature-flags.js', () => ({ + storageAutoConnectEnabled: false, + 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 { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SvelteSet } from 'svelte/reactivity'; +import { StorageState } from './state.svelte.js'; +import type { StorageObject, StoragePage } from './types.js'; +import type { StorageApi, CopyMoveResult, DeleteResult } from './api.js'; +import type { FileDetails, DirectoryMetadata, BucketDetails } from './details-types.js'; +import { StorageError } from './errors.js'; +import { addToast } from '$lib/stores/toast.svelte.js'; +import { invalidateAll } from '$app/navigation'; + +function makeObjects(): StorageObject[] { + return [ + { + key: 'file.txt', + size: 100, + lastModified: new Date('2025-01-01'), + isDirectory: false, + contentType: 'text/plain' + }, + { + key: 'dir/', + size: 0, + lastModified: new Date('2025-01-01'), + isDirectory: true, + contentType: undefined + }, + { + key: 'photo.jpg', + size: 500, + lastModified: new Date('2025-01-01'), + isDirectory: false, + contentType: 'image/jpeg' + }, + { + key: 'nested/file.js', + size: 200, + lastModified: new Date('2025-01-01'), + isDirectory: false, + contentType: 'application/javascript' + } + ]; +} + +function makePage(objects?: StorageObject[]): StoragePage { + return { + objects: objects ?? makeObjects(), + hasNextPage: false, + currentPage: 1, + pageSize: 25 + }; +} + +function makeApi(overrides?: Partial): StorageApi { + const defaults: StorageApi = { + async list() { + return makePage(); + }, + async copy(): Promise { + return { results: [], failed: 0 }; + }, + async move(): Promise { + return { results: [], failed: 0 }; + }, + async delete(): Promise { + return { failed: [] }; + }, + async create() {}, + async archiveExtract() { + return new Response(null, { status: 200 }); + }, + async archiveListing() { + return { entries: [], hasMore: false }; + }, + async pollJob() { + return { status: 'done' }; + }, + async checkObjectExists() { + return false; + }, + async download() { + return new Response(null, { status: 200 }); + }, + async preview() { + return new Response(null, { status: 200 }); + }, + async saveText() { + // no-op + }, + 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 }; +} + +function makeState( + apiOverrides?: Partial, + stateOverrides?: Partial +): StorageState { + const state = new StorageState({ + connected: true, + buckets: ['test-bucket'], + api: makeApi(apiOverrides) + }); + state.bucket = 'test-bucket'; + state.prefix = ''; + state.objects = makePage(); + if (stateOverrides) { + Object.assign(state, stateOverrides); + } + return state; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// executeAction – cut +// ──────────────────────────────────────────────────────────────────────────── + +describe('executeAction("cut")', () => { + it('sets clipboard with action="cut" and selected keys', async () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt', 'dir/']); + + await state.executeAction('cut'); + + expect(state.clipboard).not.toBeNull(); + expect(state.clipboard!.action).toBe('cut'); + expect(state.clipboard!.keys.sort()).toEqual(['dir/', 'file.txt']); + expect(state.clipboard!.sourceBucket).toBe('test-bucket'); + }); + + it('stores file sizes for non-directory items', async () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt', 'photo.jpg']); + + await state.executeAction('cut'); + + expect(state.clipboard!.fileSizes).toEqual({ 'file.txt': 100, 'photo.jpg': 500 }); + }); + + it('does nothing with empty selection', async () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(); + + await state.executeAction('cut'); + + expect(state.clipboard).toBeNull(); + }); + + it('shows an info toast with the count', async () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + await state.executeAction('cut'); + + expect(addToast).toHaveBeenCalledWith('info', expect.stringContaining('cut')); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// executeAction – copy +// ──────────────────────────────────────────────────────────────────────────── + +describe('executeAction("copy")', () => { + it('sets clipboard with action="copy" and selected keys', async () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt', 'nested/file.js']); + + await state.executeAction('copy'); + + expect(state.clipboard).not.toBeNull(); + expect(state.clipboard!.action).toBe('copy'); + expect(state.clipboard!.keys.sort()).toEqual(['file.txt', 'nested/file.js']); + }); + + it('stores file sizes for non-directory items', async () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt', 'dir/']); + + await state.executeAction('copy'); + + expect(state.clipboard!.fileSizes).toEqual({ 'file.txt': 100 }); + }); + + it('does nothing with empty selection', async () => { + const state = makeState(); + + await state.executeAction('copy'); + + expect(state.clipboard).toBeNull(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// isCutKey +// ──────────────────────────────────────────────────────────────────────────── + +describe('isCutKey', () => { + it('returns true when key is in clipboard with cut action and matching bucket', () => { + const state = makeState(); + state.clipboardState.clipboard = { + action: 'cut', + keys: ['file.txt', 'dir/'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: {} + }; + + expect(state.isCutKey('file.txt')).toBe(true); + expect(state.isCutKey('dir/')).toBe(true); + }); + + it('returns false for keys not in clipboard', () => { + const state = makeState(); + state.clipboardState.clipboard = { + action: 'cut', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: {} + }; + + expect(state.isCutKey('photo.jpg')).toBe(false); + }); + + it('returns false when clipboard action is copy', () => { + const state = makeState(); + state.clipboardState.clipboard = { + action: 'copy', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: {} + }; + + expect(state.isCutKey('file.txt')).toBe(false); + }); + + it('returns false when bucket does not match', () => { + const state = makeState(); + state.clipboardState.clipboard = { + action: 'cut', + keys: ['file.txt'], + sourceBucket: 'other-bucket', + sourcePrefix: '', + fileSizes: {} + }; + + expect(state.isCutKey('file.txt')).toBe(false); + }); + + it('returns false when clipboard is null', () => { + const state = makeState(); + state.clipboardState.clipboard = null; + + expect(state.isCutKey('file.txt')).toBe(false); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// executeAction – paste +// ──────────────────────────────────────────────────────────────────────────── + +describe('executeAction("paste")', () => { + it('shows warning when inside an archive', async () => { + const state = makeState(); + state.clipboardState.clipboard = { + action: 'copy', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: {} + }; + state.archive.archiveKey = 'archive.zip'; + + await state.executeAction('paste'); + + expect(addToast).toHaveBeenCalledWith('warning', expect.any(String)); + }); + + describe('from copy', () => { + it('calls the copy API and shows success', async () => { + const copySpy = vi.fn().mockResolvedValue({ + results: [{ sourceKey: 'file.txt', destKey: 'dest/file.txt' }], + failed: 0 + }); + const state = makeState({ copy: copySpy }); + state.clipboardState.clipboard = { + action: 'copy', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: { 'file.txt': 100 } + }; + + await state.executeAction('paste'); + + expect(copySpy).toHaveBeenCalledWith( + expect.objectContaining({ + bucket: 'test-bucket', + sourceKeys: ['file.txt'], + destinationPrefix: '' + }) + ); + expect(addToast).toHaveBeenCalledWith('success', expect.stringContaining('pasted')); + expect(invalidateAll).toHaveBeenCalled(); + }); + + it('shows error toast when all items fail (source not found)', async () => { + const copySpy = vi.fn().mockResolvedValue({ results: [], failed: 1 }); + const state = makeState({ copy: copySpy }); + state.clipboardState.clipboard = { + action: 'copy', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: {} + }; + + await state.executeAction('paste'); + + expect(addToast).toHaveBeenCalledWith( + 'error', + expect.stringMatching(/source.*deleted|not found/i) + ); + expect(invalidateAll).not.toHaveBeenCalled(); + }); + + it('shows warning when some items fail', async () => { + const copySpy = vi.fn().mockResolvedValue({ + results: [{ sourceKey: 'file.txt', destKey: 'dest/file.txt' }], + failed: 1 + }); + const state = makeState({ copy: copySpy }); + state.clipboardState.clipboard = { + action: 'copy', + keys: ['file.txt', 'photo.jpg'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: { 'file.txt': 100, 'photo.jpg': 500 } + }; + + await state.executeAction('paste'); + + expect(addToast).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('could not be pasted') + ); + }); + }); + + describe('from cut', () => { + it('calls the move API and updates clipboard to destination keys', async () => { + const moveSpy = vi.fn().mockResolvedValue({ + results: [{ sourceKey: 'file.txt', destKey: 'dest/file.txt' }], + failed: 0 + }); + const state = makeState({ move: moveSpy }); + state.clipboardState.clipboard = { + action: 'cut', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: { 'file.txt': 100 } + }; + + await state.executeAction('paste'); + + expect(moveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + bucket: 'test-bucket', + sourceKeys: ['file.txt'] + }) + ); + // Clipboard updated to destination keys with action='copy' + expect(state.clipboard).not.toBeNull(); + expect(state.clipboard!.action).toBe('copy'); + expect(state.clipboard!.keys).toEqual(['dest/file.txt']); + expect(state.clipboard!.sourceBucket).toBe('test-bucket'); + }); + + it('keeps original clipboard on failed move (no results)', async () => { + const moveSpy = vi.fn().mockResolvedValue({ results: [], failed: 1 }); + const state = makeState({ move: moveSpy }); + state.clipboardState.clipboard = { + action: 'cut', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: { 'file.txt': 100 } + }; + + await state.executeAction('paste'); + + // Clipboard unchanged (still cut keys) + expect(state.clipboard!.action).toBe('cut'); + expect(state.clipboard!.keys).toEqual(['file.txt']); + }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// confirmRename +// ──────────────────────────────────────────────────────────────────────────── + +describe('confirmRename', () => { + it('closes modal if new name is same as current', async () => { + const state = makeState(); + state.openModal('rename', { key: 'file.txt' }); + expect(state.activeModal).not.toBeNull(); + + await state.confirmRename('file.txt', 'file.txt'); + + expect(state.activeModal).toBeNull(); + expect(state.renameLoading).toBe(false); + }); + + it('shows inline error on conflict and keeps modal open', async () => { + const moveSpy = vi + .fn() + .mockRejectedValue(new StorageError('conflict', 'Object already exists')); + const state = makeState({ move: moveSpy }); + state.openModal('rename', { key: 'file.txt' }); + + await state.confirmRename('file.txt', 'renamed.txt'); + + expect(state.activeModal?.type).toBe('rename'); + expect(state.renameError).toContain('already exists'); + expect(state.renameLoading).toBe(false); + }); + + it('shows toast on access_denied and closes modal', async () => { + const moveSpy = vi.fn().mockRejectedValue(new StorageError('access_denied', 'Access denied')); + const state = makeState({ move: moveSpy }); + state.openModal('rename', { key: 'file.txt' }); + + await state.confirmRename('file.txt', 'renamed.txt'); + + expect(state.activeModal).toBeNull(); + expect(addToast).toHaveBeenCalledWith('error', expect.stringContaining('denied')); + }); + + it('shows toast on not_found and closes modal', async () => { + const moveSpy = vi.fn().mockRejectedValue(new StorageError('not_found', 'Not found')); + const state = makeState({ move: moveSpy }); + state.openModal('rename', { key: 'file.txt' }); + + await state.confirmRename('file.txt', 'renamed.txt'); + + expect(state.activeModal).toBeNull(); + expect(addToast).toHaveBeenCalledWith('error', expect.stringContaining('could not be found')); + }); + + it('shows success toast on successful rename and updates recent files', async () => { + const moveSpy = vi.fn().mockResolvedValue({ results: [], failed: 0 }); + const state = makeState({ move: moveSpy }); + state.openModal('rename', { key: 'file.txt' }); + // Seed a recent file entry for the old key + state.bookmarks.recordFileVisit('test-bucket', 'file.txt', 100); + + await state.confirmRename('file.txt', 'renamed.txt'); + + expect(state.activeModal).toBeNull(); + expect(addToast).toHaveBeenCalledWith('success', expect.stringContaining('Renamed')); + expect(invalidateAll).toHaveBeenCalled(); + // Recent files updated + expect(state.bookmarks.recentFiles.some((f) => f.key === 'renamed.txt')).toBe(true); + expect(state.bookmarks.recentFiles.some((f) => f.key === 'file.txt')).toBe(false); + }); + + it('handles directory rename (trailing slash)', async () => { + const moveSpy = vi.fn().mockResolvedValue({ results: [], failed: 0 }); + const state = makeState({ move: moveSpy }); + state.openModal('rename', { key: 'dir/' }); + + await state.confirmRename('dir/', 'renamed-dir'); + + expect(addToast).toHaveBeenCalledWith('success', expect.stringContaining('Renamed')); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// performMove (drag-and-drop) + confirmMove +// ──────────────────────────────────────────────────────────────────────────── + +describe('performMove', () => { + it('does nothing when destination is the same prefix', () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + state.performMove(''); + + expect(state.activeModal).toBeNull(); + }); + + it('does nothing when moving a folder into itself', () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['dir/']); + + state.performMove('dir/sub/'); + + expect(state.activeModal).toBeNull(); + }); + + it('opens confirm-move modal with selected keys', () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + state.performMove('dest/'); + + expect(state.activeModal?.type).toBe('confirm-move'); + const payload = ( + state.activeModal as { type: 'confirm-move'; payload: { keys: string[]; destPrefix: string } } + )?.payload; + expect(payload?.keys).toEqual(['file.txt']); + expect(payload?.destPrefix).toBe('dest/'); + }); + + it('uses explicit keys when provided', () => { + const state = makeState(); + + state.performMove('dest/', ['dir/file.ts']); + + expect(state.activeModal?.type).toBe('confirm-move'); + const payload = (state.activeModal as { type: 'confirm-move'; payload: { keys: string[] } }) + ?.payload; + expect(payload?.keys).toEqual(['dir/file.ts']); + }); + + it('does nothing when storageMoveEnabled is false', () => { + // storageMoveEnabled is set to true in the module-level vi.mock above, so + // this test verifies the same-prefix guard which also prevents the modal + // from opening (the flag guard is covered by the integration with the + // component-level mocks in other test files). + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + // Same prefix → should not open modal regardless of flag + state.performMove(''); + + expect(state.activeModal).toBeNull(); + }); +}); + +describe('confirmMove', () => { + it('calls the move API and shows success toast', async () => { + const moveSpy = vi.fn().mockResolvedValue({ + results: [{ sourceKey: 'file.txt', destKey: 'dest/file.txt' }], + failed: 0 + }); + const state = makeState({ move: moveSpy }); + state.selectedKeys = new SvelteSet(['file.txt']); + state.performMove('dest/'); + + await state.confirmMove(); + + expect(moveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + bucket: 'test-bucket', + sourceKeys: ['file.txt'], + destinationPrefix: 'dest/' + }) + ); + expect(addToast).toHaveBeenCalledWith('success', expect.stringContaining('moved')); + expect(state.activeModal).toBeNull(); + }); + + it('shows warning on partial failures', async () => { + const moveSpy = vi.fn().mockResolvedValue({ + results: [{ sourceKey: 'file.txt', destKey: 'dest/file.txt' }], + failed: 1 + }); + const state = makeState({ move: moveSpy }); + state.selectedKeys = new SvelteSet(['file.txt', 'photo.jpg']); + state.performMove('dest/'); + + await state.confirmMove(); + + expect(addToast).toHaveBeenCalledWith('warning', expect.stringContaining('could not be moved')); + }); + + it('cancelMove closes the modal without calling API', () => { + const moveSpy = vi.fn(); + const state = makeState({ move: moveSpy }); + state.selectedKeys = new SvelteSet(['file.txt']); + state.performMove('dest/'); + + state.cancelMove(); + + expect(state.activeModal).toBeNull(); + expect(moveSpy).not.toHaveBeenCalled(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Keyboard shortcuts +// ──────────────────────────────────────────────────────────────────────────── + +describe('handleKeydown', () => { + function dispatch(state: StorageState, key: string, ctrl = false, meta = false): void { + const event = new KeyboardEvent('keydown', { + key, + ctrlKey: ctrl, + metaKey: meta, + bubbles: true + }); + state.handleKeydown(event); + } + + it('Ctrl+X triggers cut when items selected', () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + dispatch(state, 'x', true); + + expect(state.clipboard).not.toBeNull(); + expect(state.clipboard!.action).toBe('cut'); + }); + + it('Ctrl+C triggers copy when items selected', () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + dispatch(state, 'c', true); + + expect(state.clipboard).not.toBeNull(); + expect(state.clipboard!.action).toBe('copy'); + }); + + it('Ctrl+V triggers paste when clipboard non-empty', async () => { + const copySpy = vi.fn().mockResolvedValue({ + results: [{ sourceKey: 'file.txt', destKey: 'paste/file.txt' }], + failed: 0 + }); + const state = makeState({ copy: copySpy }); + state.clipboardState.clipboard = { + action: 'copy', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: {} + }; + + await state.executeAction('paste'); + + expect(addToast).toHaveBeenCalledWith('success', expect.any(String)); + }); + + it('Ctrl+V does nothing when clipboard is empty', () => { + const state = makeState(); + state.clipboardState.clipboard = null; + + dispatch(state, 'v', true); + + expect(addToast).not.toHaveBeenCalled(); + }); + + it('F2 triggers rename when one item is selected', () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + dispatch(state, 'F2'); + + expect(state.activeModal?.type).toBe('rename'); + }); + + it('F2 does nothing when no items selected', () => { + const state = makeState(); + + dispatch(state, 'F2'); + + expect(state.activeModal).toBeNull(); + }); + + it('Delete opens delete modal', () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + dispatch(state, 'Delete'); + + expect(state.activeModal?.type).toBe('delete'); + }); + + it('Escape clears selection', () => { + const state = makeState(); + state.selectedKeys = new SvelteSet(['file.txt']); + + dispatch(state, 'Escape'); + + expect(state.selectedKeys.size).toBe(0); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Clipboard cleanup on delete +// ──────────────────────────────────────────────────────────────────────────── + +describe('performDelete clipboard cleanup', () => { + it('removes deleted keys from clipboard', async () => { + const deleteSpy = vi.fn().mockResolvedValue({ failed: [] }); + const state = makeState({ delete: deleteSpy }); + state.clipboardState.clipboard = { + action: 'cut', + keys: ['file.txt', 'photo.jpg', 'nested/file.js'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: { 'file.txt': 100, 'photo.jpg': 500, 'nested/file.js': 200 } + }; + state.selectedKeys = new SvelteSet(['file.txt', 'photo.jpg']); + state.openModal('delete', { keys: ['file.txt', 'photo.jpg'] }); + + await state.confirmDelete(); + + expect(state.clipboard!.keys).toEqual(['nested/file.js']); + }); + + it('clears clipboard when all keys are deleted', async () => { + const deleteSpy = vi.fn().mockResolvedValue({ failed: [] }); + const state = makeState({ delete: deleteSpy }); + state.clipboardState.clipboard = { + action: 'copy', + keys: ['file.txt'], + sourceBucket: 'test-bucket', + sourcePrefix: '', + fileSizes: { 'file.txt': 100 } + }; + state.selectedKeys = new SvelteSet(['file.txt']); + state.openModal('delete', { keys: ['file.txt'] }); + + await state.confirmDelete(); + + expect(state.clipboard).toBeNull(); + }); + + it('does not affect clipboard when source bucket differs', async () => { + const deleteSpy = vi.fn().mockResolvedValue({ failed: [] }); + const state = makeState({ delete: deleteSpy }); + state.clipboardState.clipboard = { + action: 'copy', + keys: ['file.txt'], + sourceBucket: 'other-bucket', + sourcePrefix: '', + fileSizes: {} + }; + state.selectedKeys = new SvelteSet(['file.txt']); + state.openModal('delete', { keys: ['file.txt'] }); + + await state.confirmDelete(); + + expect(state.clipboard).not.toBeNull(); + expect(state.clipboard!.keys).toEqual(['file.txt']); + }); +}); diff --git a/src/lib/storage/state.svelte.ts b/src/lib/storage/state.svelte.ts index d83d45c1..1cb427de 100644 --- a/src/lib/storage/state.svelte.ts +++ b/src/lib/storage/state.svelte.ts @@ -1,4 +1,5 @@ -import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity'; +import { SvelteSet } from 'svelte/reactivity'; +import { tick } from 'svelte'; import { invalidateAll } from '$app/navigation'; import * as m from '$lib/paraglide/messages.js'; import type { StoragePage, StorageObject } from '$lib/storage/types.js'; @@ -8,16 +9,28 @@ import type { ModalPayloads, ContextMenuState, NavigateFn, - ActionName + ActionName, + StorageOperation } from '$lib/storage/types.js'; import { initPageSize, type PageSize } from '$lib/types/pagination.js'; -import { defaultPageSize } from '$lib/client/feature-flags.js'; -import { downloadObject, DownloadError } from '$lib/storage/download.js'; +import { + defaultPageSize, + storageCutCopyEnabled, + storagePasteEnabled, + storageRenameEnabled +} from '$lib/client/feature-flags.js'; +import { downloadObject } from '$lib/storage/download.js'; +import type { ConflictEntry } from '$lib/components/storage/modals/shared/conflict-types.js'; import { addToast } from '$lib/stores/toast.svelte.js'; -import { ActionError, getActionErrorMessage } from './errors.js'; +import { StorageError, getActionErrorMessage } from './errors.js'; import { BookmarksState } from './bookmarks.svelte.js'; -import { loadConnectionLocally, getConnectionHeader } from '$lib/storage/connection-storage.js'; -import { keyToName } from '$lib/storage/utils.js'; +import { connectionStore } from '$lib/storage/connection-store.svelte.js'; +import { isArchiveExtension, keyToName } from '$lib/storage/utils.js'; +import type { StorageApi } from './api.js'; +import { createFetchStorageApi } from './api.js'; +import { OperationsState } from './operations.svelte.js'; +import { ArchiveState } from './archive.svelte.js'; +import { ClipboardState } from './clipboard.svelte.js'; export class StorageState { // ── Core data (synced from server load) ── @@ -58,17 +71,17 @@ export class StorageState { contextMenu = $state(null); get ctxFileObj(): StorageObject | null { - if (!this.contextMenu) return null; + if (!this.contextMenu?.key) return null; return this.files.find((f: StorageObject) => f.key === this.contextMenu!.key) ?? null; } get ctxIsFile(): boolean { return this.ctxFileObj !== null; } get canPin(): boolean { - return this.contextMenu !== null && !this.ctxIsFile; + return this.contextMenu !== null && !!this.contextMenu.key && !this.ctxIsFile; } get ctxIsPinned(): boolean { - if (!this.contextMenu || this.ctxIsFile) return false; + if (!this.contextMenu?.key || this.ctxIsFile) return false; return this.bookmarks.isPinned(this.bucket, this.contextMenu.key); } @@ -76,6 +89,14 @@ export class StorageState { loading = $state(false); deleting = $state(false); + // ── Rename inline ── + renameLoading = $state(false); + renameError = $state(null); + + // ── Connection identity ── + connectionId = $state(null); + connectionHostname = $state(''); + // ── Pagination ── prevTokens = $state<(string | null)[]>([]); pageSize = $state(initPageSize('storage_page_size')); @@ -83,18 +104,110 @@ export class StorageState { // ── Composed sub-state ── bookmarks: BookmarksState; + archive: ArchiveState; + clipboardState: ClipboardState; + + /** + * Delegated clipboard data getter for backward compatibility. + * Components access `storage.clipboard` to read the current clipboard. + */ + get clipboard(): import('$lib/storage/types.js').ClipboardData | null { + return this.clipboardState.clipboard; + } + + // ── Operations (paste / move / rename progress) ── + private operations_: OperationsState; + + get operations(): StorageOperation[] { + return this.operations_.operations; + } + + get hasRunningOps(): boolean { + return this.operations_.hasRunningOps; + } + + /** Delegated to clipboardState. */ + isCutKey(key: string): boolean { + return this.clipboardState.isCutKey(key, this.bucket); + } // ── Navigation handler (injected by page component) ── private _onNavigate: NavigateFn = () => {}; + // ── Refresh handler (injected by page component) ── + // Navigates to the current bucket/prefix using replaceState so that a + // refresh does not add an extra browser history entry. Falls back to + // invalidateAll when no handler has been set (e.g. in tests). + private _onRefreshNavigate: (() => void) | null = null; + // ── Tabs invalidation callback (injected by FileExplorer) ── + // Called after file operations to mark source tabs as stale so they refetch + // when the user switches back to them. + private _onInvalidateSourceTabs: ((prefix: string) => void) | null = null; // ──────────────────────────────────────────────────────────────────────────── // Constructor // ──────────────────────────────────────────────────────────────────────────── - constructor(options?: { connected?: boolean; buckets?: string[]; connectionId?: string }) { + private _api: StorageApi; + + constructor(options?: { + connected?: boolean; + buckets?: string[]; + connectionId?: string | null; + api?: StorageApi; + }) { if (options?.connected !== undefined) this.connected = options.connected; if (options?.buckets) this.buckets = options.buckets; + if (options?.connectionId !== undefined) this.connectionId = options.connectionId; + this._api = options?.api ?? createFetchStorageApi(() => connectionStore.activeConnectionId); this.bookmarks = new BookmarksState(options?.connectionId ?? ''); + this.operations_ = new OperationsState(this._api, { + getBucket: () => this.bucket, + getPrefix: () => this.prefix, + onRefresh: () => this.refresh(), + onInvalidateTabs: (prefix) => this._onInvalidateSourceTabs?.(prefix) + }); + this.archive = new ArchiveState(this._api, { + getBucket: () => this.bucket, + getPrefix: () => this.prefix, + getPageSize: () => this.pageSize, + setObjects: (objects) => { + this.objects = objects; + }, + setLoading: (loading) => { + this.loading = loading; + }, + setPrefix: (prefix) => { + this.prefix = prefix; + }, + clearPrevTokens: () => { + this.prevTokens = []; + }, + onExit: (s3Prefix) => { + this.loading = true; + this.prevTokens = []; + void this.archive._fetchS3Objects(s3Prefix); + } + }); + this.clipboardState = new ClipboardState(this._api, this.operations_, { + getBucket: () => this.bucket, + getPrefix: () => this.prefix, + getObjects: () => this.objects, + getSelectedKeys: () => this.selectedKeys, + openModal: (type, payload) => this.openModal(type as never, payload as never), + closeModal: () => this.closeModal(), + refresh: () => this.refresh(), + invalidateSourceTabs: (prefix) => this._onInvalidateSourceTabs?.(prefix), + recordFileVisit: (bucket, key, size) => this.bookmarks.recordFileVisit(bucket, key, size), + removeFiles: (bucket, keys) => this.bookmarks.removeFiles(bucket, keys), + clearSelection: () => { + this.selectedKeys = new SvelteSet(); + this.selectionMode = false; + } + }); + } + + get api(): StorageApi { + return this._api; } // ──────────────────────────────────────────────────────────────────────────── @@ -108,14 +221,29 @@ export class StorageState { this.objects = objects; if (bucketChanged) this.prevTokens = []; this.loading = false; - // Clear selection on navigation this.selectedKeys = new SvelteSet(); + this.archive.reset(); + } + + /** Add a bucket to the in-memory list (no server-side persistence). */ + addBucket(name: string): void { + if (!this.buckets.includes(name)) { + this.buckets = [...this.buckets, name]; + } } setNavigationHandler(fn: NavigateFn): void { this._onNavigate = fn; } + setRefreshHandler(fn: () => void): void { + this._onRefreshNavigate = fn; + } + + setTabsInvalidationHandler(fn: (prefix: string) => void): void { + this._onInvalidateSourceTabs = fn; + } + // ──────────────────────────────────────────────────────────────────────────── // Selection // ──────────────────────────────────────────────────────────────────────────── @@ -184,8 +312,16 @@ export class StorageState { }; refresh = (): void => { - this.loading = true; - void invalidateAll(); + if (this.archive.isInArchive) { + this.archive.refreshListing(); + } else { + this.loading = true; + if (this._onRefreshNavigate) { + this._onRefreshNavigate(); + } else { + void invalidateAll(); + } + } }; onPageSizeChange = (): void => { @@ -220,7 +356,18 @@ export class StorageState { this.contextMenu = { x: e.clientX, y: e.clientY, key }; }; + /** Open the context menu for the empty space (no specific item). */ + openEmptyContextMenu = (e: MouseEvent): void => { + e.preventDefault(); + e.stopPropagation(); + this.selectedKeys = new SvelteSet(); + this.contextMenu = { x: e.clientX, y: e.clientY }; + }; + closeContextMenu = (): void => { + if (this.contextMenu?.key) { + this.selectedKeys.delete(this.contextMenu.key); + } this.contextMenu = null; }; @@ -252,6 +399,19 @@ export class StorageState { addToast('warning', m.storage_action_preview_no_selection()); return; } + if (this.archive.isInArchive) { + this.openModal('preview', { + key, + archiveKey: this.archive.archiveKey!, + archivePath: key, + nestedArchivePath: this.archive.nestedArchivePath + }); + return; + } + if (isArchiveExtension(key)) { + void this.archive.enterArchive(key); + return; + } for (const f of effectiveSelectedFiles) { this.bookmarks.recordFileVisit(this.bucket, f.key, f.size); } @@ -263,19 +423,23 @@ export class StorageState { addToast('warning', m.storage_action_download_no_selection()); return; } + if (this.archive.isInArchive) { + void this.archive.downloadFromArchive(key); + return; + } for (const f of effectiveSelectedFiles) { this.bookmarks.recordFileVisit(this.bucket, f.key, f.size); } try { - const conn = loadConnectionLocally(); - if (!conn) { + const connectionId = connectionStore.activeConnectionId; + if (!connectionId) { addToast('error', m.storage_download_error_unknown()); return; } - await downloadObject(this.bucket, key, getConnectionHeader(conn)); + await downloadObject(this.bucket, key, connectionId); } catch (err: unknown) { - if (err instanceof DownloadError) { - addToast('error', getActionErrorMessage(new ActionError(err.code, err.message))); + if (err instanceof StorageError) { + addToast('error', getActionErrorMessage(err)); } else { addToast('error', m.storage_download_error_unknown()); } @@ -293,31 +457,147 @@ export class StorageState { case 'copy-filename': { const nameKey = ctxKey ?? this.selectedFiles[0]?.key; if (!nameKey) return; - try { - await navigator.clipboard.writeText(keyToName(nameKey)); - addToast('success', m.storage_action_copy_filename_success()); - } catch { - addToast('error', m.storage_action_copy_filename_error()); - } + await this.copyFilename(nameKey); return; } case 'copy-path': { const pathKey = ctxKey ?? this.selectedFiles[0]?.key; if (!pathKey) return; - // Strip trailing slash for folders so the URI is canonical. - const cleanKey = pathKey.endsWith('/') ? pathKey.slice(0, -1) : pathKey; - try { - await navigator.clipboard.writeText(`s3://${this.bucket}/${cleanKey}`); - addToast('success', m.storage_action_copy_path_success()); - } catch { - addToast('error', m.storage_action_copy_path_error()); + await this.copyPath(this.bucket, pathKey); + return; + } + + case 'details': { + const targetKey = ctxKey ?? this.selectedFiles[0]?.key ?? this.selectedFolders[0]?.key; + if (!targetKey) { + addToast('warning', m.storage_action_preview_no_selection()); + return; } + const isDir = targetKey.endsWith('/'); + this.openModal('details', { + type: isDir ? 'directory' : 'file', + bucket: this.bucket, + key: targetKey, + prefix: isDir ? targetKey : undefined + }); + return; + } + + case 'cut': { + if (!storageCutCopyEnabled) return; + const cutKeys = [...this.selectedKeys]; + if (cutKeys.length === 0) return; + this.clipboardState.cut(cutKeys, this.objects, this.bucket, this.prefix); + return; + } + + case 'copy': { + if (!storageCutCopyEnabled) return; + const copyKeys = [...this.selectedKeys]; + if (copyKeys.length === 0) return; + this.clipboardState.copy(copyKeys, this.objects, this.bucket, this.prefix); return; } + + case 'paste': { + if (!storagePasteEnabled) return; + if (!this.clipboardState.clipboard || this.clipboardState.clipboard.keys.length === 0) + return; + if (this.archive.isInArchive) { + addToast('warning', m.storage_action_paste_archive_error()); + return; + } + const ctxKey = this.contextMenu?.key ?? null; + const destPrefix = ctxKey && ctxKey.endsWith('/') ? ctxKey : this.prefix; + await this.clipboardState.paste(destPrefix); + return; + } + + case 'rename': { + if (!storageRenameEnabled) return; + const renameKey = ctxKey ?? [...this.selectedKeys][0]; + if (!renameKey) return; + this.renameError = null; + this.renameLoading = false; + this.openModal('rename', { key: renameKey }); + return; + } + + case 'create-file': + this.openModal('create', { type: 'file' }); + return; + + case 'create-folder': + this.openModal('create', { type: 'folder' }); + return; + } + }; + + copyFilename = async (key: string): Promise => { + try { + const name = key ? keyToName(key) : this.bucket; + await navigator.clipboard.writeText(name); + const isDir = !key || key.endsWith('/'); + addToast( + 'success', + isDir + ? m.storage_action_copy_directory_name_success() + : m.storage_action_copy_filename_success() + ); + } catch { + const isDir = !key || key.endsWith('/'); + addToast( + 'error', + isDir + ? m.storage_action_copy_directory_name_error() + : m.storage_action_copy_filename_error() + ); + } + }; + + copyPath = async (bucket: string, key: string): Promise => { + const cleanKey = key.endsWith('/') ? key.slice(0, -1) : key; + try { + await navigator.clipboard.writeText(`s3://${bucket}/${cleanKey}`); + addToast('success', m.storage_action_copy_path_success()); + } catch { + addToast('error', m.storage_action_copy_path_error()); + } + }; + + confirmCreate = async (name: string, type: 'file' | 'folder'): Promise => { + const sanitized = name.trim(); + if (!sanitized || sanitized === '.' || sanitized === '..') return; + + this.closeModal(); + this.loading = true; + + try { + const isFolder = type === 'folder'; + const parts = sanitized.split('/'); + + // Create intermediate directory markers + for (let i = 0; i < parts.length - 1; i++) { + const dirKey = this.prefix + parts.slice(0, i + 1).join('/') + '/'; + await this.api.create({ bucket: this.bucket, key: dirKey }); + } + + // Create the final object (file or directory) + const finalKey = this.prefix + sanitized + (isFolder ? '/' : ''); + await this.api.create({ bucket: this.bucket, key: finalKey }); + + void invalidateAll(); + } catch { + addToast('error', m.storage_create_error({ name: sanitized })); + this.loading = false; } }; + cancelCreate = (): void => { + this.closeModal(); + }; + confirmDelete = async (): Promise => { const modal = this.activeModal; if (!modal || modal.type !== 'delete') return; @@ -333,12 +613,11 @@ export class StorageState { } this.selectedKeys = new SvelteSet(); this.selectionMode = false; - this.loading = true; - await invalidateAll(); + this.refresh(); } catch (err: unknown) { this.loading = false; let msg = m.storage_delete_error_unknown(); - if (err instanceof ActionError) { + if (err instanceof StorageError) { if (err.code === 'not_connected') msg = m.storage_delete_error_not_connected(); else if (err.code === 'access_denied') msg = m.storage_delete_error_access_denied(); else if (err.code === 'server_error') msg = m.storage_delete_error_server_error(); @@ -355,16 +634,134 @@ export class StorageState { handleUploadSuccess = (): void => { this.closeModal(); - this.loading = true; - void invalidateAll(); + this.refresh(); + }; + + // ── Delegate methods ─────────────────────────────────────────────────────── + + /** Delegated to clipboardState. */ + performMove = (destPrefix: string, keys?: string[]): void => { + this.clipboardState.performMove(destPrefix, keys); + }; + + /** Delegated to clipboardState. */ + confirmMove = (): Promise => { + return this.clipboardState.confirmMove(); }; + /** Delegated to clipboardState. */ + cancelMove = (): void => { + this.clipboardState.cancelMove(); + }; + + /** Delegated to clipboardState. */ + confirmConflictResolution = (entries: ConflictEntry[]): Promise => { + return this.clipboardState.confirmConflictResolution(entries); + }; + + /** Delegated to clipboardState. */ + cancelConflictResolution = (): void => { + this.clipboardState.cancelConflictResolution(); + }; + + // ──────────────────────────────────────────────────────────────────────────── + // Rename + // ──────────────────────────────────────────────────────────────────────────── + + confirmRename = async (key: string, newName: string): Promise => { + this.renameLoading = true; + this.renameError = null; + + if (!newName.trim()) { + this.renameLoading = false; + return; + } + + const parts = key.split('/').filter(Boolean); + parts.pop(); + const parentPrefix = parts.length > 0 ? parts.join('/') + '/' : ''; + const newKey = parentPrefix + newName + (key.endsWith('/') ? '/' : ''); + + if (newKey === key) { + this.renameLoading = false; + this.closeModal(); + return; + } + + const opId = crypto.randomUUID(); + const renameObj = this.files.find((f) => f.key === key); + this.operations_.startOp( + opId, + `${m.storage_operation_rename()}: ${keyToName(key)} → ${newName}`, + 'rename', + 1, + undefined, + `${this.bucket}/${newKey}`, + [keyToName(key)], + renameObj?.size ?? 0 + ); + + try { + try { + await this.api.move({ + bucket: this.bucket, + sourceKeys: [key], + destinationPrefix: '', + destinationKey: newKey + }); + } catch (err: unknown) { + this.operations_.finishOp(opId, 'error'); + this.renameLoading = false; + if (err instanceof StorageError && err.code === 'conflict') { + this.renameError = m.storage_rename_error_conflict({ name: newName }); + return; + } + this.closeModal(); + let msg = m.storage_rename_error({ name: newName }); + if (err instanceof StorageError && err.code === 'access_denied') + msg = m.storage_rename_error_access_denied(); + else if (err instanceof StorageError && err.code === 'not_found') + msg = m.storage_rename_error_not_found(); + addToast('error', msg); + return; + } + + // Update recent files + if (!key.endsWith('/')) { + this.bookmarks.removeFiles(this.bucket, [key]); + const obj = this.files.find((f) => f.key === key); + if (obj) { + this.bookmarks.recordFileVisit(this.bucket, newKey, obj.size); + } + } + + this.operations_.updateOpProgress(opId, 1, renameObj?.size ?? 0); + await tick(); + this.operations_.finishOp(opId, 'done'); + this.renameLoading = false; + this.closeModal(); + addToast('success', m.storage_rename_success({ name: newName })); + this.refresh(); + } catch { + this.operations_.finishOp(opId, 'error'); + this.renameLoading = false; + this.closeModal(); + addToast('error', m.storage_rename_error({ name: newName })); + } + }; + + // ── (Move and conflict resolution moved to ClipboardState) ── + // ──────────────────────────────────────────────────────────────────────────── // Keyboard shortcuts // ──────────────────────────────────────────────────────────────────────────── handleKeydown = (e: KeyboardEvent): void => { - if (this.activeModal?.type === 'delete') return; + if (this.activeModal) return; + if (this.archive.isInArchive && e.key !== 'Escape') return; + + const isCtrl = e.ctrlKey || e.metaKey; + if (e.key === 'Delete' && this.selectedKeys.size > 0) { this.openModal('delete', { keys: [...this.selectedKeys] }); } else if (e.key === 'Escape') { @@ -373,9 +770,42 @@ export class StorageState { } else { this.clearSelection(); } + if (this.selectedKeys.size > 0) { + this.clearSelection(); + } + } else if (isCtrl && e.key === 'x') { + e.preventDefault(); + if (!this.archive.isInArchive && this.selectedKeys.size > 0) { + void this.executeAction('cut'); + } + } else if (isCtrl && e.key === 'c') { + e.preventDefault(); + if (!this.archive.isInArchive && this.selectedKeys.size > 0) { + void this.executeAction('copy'); + } + } else if (isCtrl && e.key === 'v') { + e.preventDefault(); + if (!this.archive.isInArchive && this.clipboard) { + void this.executeAction('paste'); + } + } else if (isCtrl && e.key === 'a') { + e.preventDefault(); + this.selectAll(!this.allSelected); + } else if (e.key === 'F2') { + if (!this.archive.isInArchive && this.selectedKeys.size === 1) { + void this.executeAction('rename'); + } } }; + cancelOp = (id: string): void => { + this.operations_.cancelOp(id); + }; + + clearOperationHistory = (): void => { + this.operations_.clearOperationHistory(); + }; + // ──────────────────────────────────────────────────────────────────────────── // Private: Delete implementation // ──────────────────────────────────────────────────────────────────────────── @@ -384,25 +814,7 @@ export class StorageState { bucket: string, keys: string[] ): Promise<{ failed: Array<{ key: string; code?: string; message?: string }> }> { - const params = new SvelteURLSearchParams({ bucket }); - for (const key of keys) params.append('keys', key); - - const conn = loadConnectionLocally(); - const headers: HeadersInit = conn ? { 'x-storage-connection': getConnectionHeader(conn) } : {}; - - const res = await fetch(`/api/storage/delete?${params}`, { method: 'DELETE', headers }); - if (!res.ok) { - let code: string; - if (res.status === 401) code = 'not_connected'; - else if (res.status === 403) code = 'access_denied'; - else if (res.status >= 500) code = 'server_error'; - else code = 'unknown'; - throw new ActionError(code, `Delete failed with status ${res.status}`); - } - - const result = (await res.json()) as { - failed: Array<{ key: string; code?: string; message?: string }>; - }; + const result = await this.api.delete({ bucket, keys }); // Clean up pinned locations and recent items for deleted paths const dirPrefixes = keys.filter((k) => k.endsWith('/')); @@ -415,6 +827,9 @@ export class StorageState { this.bookmarks.removeFiles(bucket, fileKeys); } + // Remove deleted keys from clipboard if they match the current bucket + this.clipboardState.removeDeletedKeys(bucket, keys); + return result; } } diff --git a/src/lib/storage/storage-fetch.spec.ts b/src/lib/storage/storage-fetch.spec.ts new file mode 100644 index 00000000..632868be --- /dev/null +++ b/src/lib/storage/storage-fetch.spec.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createStorageFetch, mapStatusToCode } from './storage-fetch.js'; +import { StorageError } from './errors.js'; +import { STORAGE_CONNECTION_ID_HEADER } from './connection-id-header.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeResponse(opts?: { status?: number; body?: string }): Response { + return new Response(opts?.body ?? null, { + status: opts?.status ?? 200 + }); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('mapStatusToCode', () => { + it('maps 401 → not_connected', () => { + expect(mapStatusToCode(401)).toBe('not_connected'); + }); + + it('maps 403 → access_denied', () => { + expect(mapStatusToCode(403)).toBe('access_denied'); + }); + + it('maps 404 → not_found', () => { + expect(mapStatusToCode(404)).toBe('not_found'); + }); + + it('maps 500 → server_error', () => { + expect(mapStatusToCode(500)).toBe('server_error'); + }); + + it('maps 502 → server_error', () => { + expect(mapStatusToCode(502)).toBe('server_error'); + }); + + it('maps 503 → server_error', () => { + expect(mapStatusToCode(503)).toBe('server_error'); + }); + + it('maps 200 → unknown', () => { + expect(mapStatusToCode(200)).toBe('unknown'); + }); + + it('maps 400 → unknown', () => { + expect(mapStatusToCode(400)).toBe('unknown'); + }); +}); + +describe('createStorageFetch', () => { + it('throws StorageError with not_connected when getConnectionId returns null', async () => { + const storageFetch = createStorageFetch(() => null); + + await expect(storageFetch('/api/test')).rejects.toThrow(StorageError); + await expect(storageFetch('/api/test')).rejects.toMatchObject({ + code: 'not_connected' + }); + }); + + it('throws StorageError with not_connected when getConnectionId returns undefined', async () => { + const storageFetch = createStorageFetch(() => undefined as unknown as string | null); + + await expect(storageFetch('/api/test')).rejects.toThrow(StorageError); + await expect(storageFetch('/api/test')).rejects.toMatchObject({ + code: 'not_connected' + }); + }); + + it('sets the connection ID header on the request', async () => { + const storageFetch = createStorageFetch(() => 'test-conn-id'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse()); + + await storageFetch('/api/storage/list?bucket=test'); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [, init] = fetchSpy.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get(STORAGE_CONNECTION_ID_HEADER)).toBe('test-conn-id'); + }); + + it('preserves custom headers from the caller', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse()); + + await storageFetch('/api/test', { + headers: { 'X-Custom': 'value' }, + method: 'POST' + }); + + const [, init] = fetchSpy.mock.calls[0]!; + expect(init?.method).toBe('POST'); + const headers = new Headers(init?.headers); + expect(headers.get('X-Custom')).toBe('value'); + expect(headers.get(STORAGE_CONNECTION_ID_HEADER)).toBe('conn-1'); + }); + + it('returns the response on success', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + const body = JSON.stringify({ ok: true }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse({ body })); + + const res = await storageFetch('/api/test'); + expect(res.ok).toBe(true); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('throws StorageError on 403 with access_denied code', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse({ status: 403 })); + + await expect(storageFetch('/api/test')).rejects.toThrow(StorageError); + await expect(storageFetch('/api/test')).rejects.toMatchObject({ + code: 'access_denied' + }); + }); + + it('throws StorageError on 404 with not_found code', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse({ status: 404 })); + + await expect(storageFetch('/api/test')).rejects.toThrow(StorageError); + await expect(storageFetch('/api/test')).rejects.toMatchObject({ + code: 'not_found' + }); + }); + + it('throws StorageError on 500 with server_error code', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse({ status: 500 })); + + await expect(storageFetch('/api/test')).rejects.toThrow(StorageError); + await expect(storageFetch('/api/test')).rejects.toMatchObject({ + code: 'server_error' + }); + }); + + it('throws StorageError on 401 with not_connected code', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse({ status: 401 })); + + await expect(storageFetch('/api/test')).rejects.toThrow(StorageError); + await expect(storageFetch('/api/test')).rejects.toMatchObject({ + code: 'not_connected' + }); + }); + + it('throws StorageError on 400 with unknown code', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse({ status: 400 })); + + await expect(storageFetch('/api/test')).rejects.toMatchObject({ + code: 'unknown' + }); + }); + + it('calls fetch with the correct path', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse()); + + await storageFetch('/api/storage/list?bucket=my-bucket&prefix=foo/'); + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/storage/list?bucket=my-bucket&prefix=foo/', + expect.objectContaining({ headers: expect.any(Headers) }) + ); + }); + + it('forwards body and method from init', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse()); + const body = JSON.stringify({ sourceKeys: ['a.txt'], destinationPrefix: 'dest/' }); + + await storageFetch('/api/storage/copy?bucket=b', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }); + + const [, init] = fetchSpy.mock.calls[0]!; + expect(init?.method).toBe('POST'); + expect(init?.body).toBe(body); + }); + + it('forwards AbortSignal', async () => { + const storageFetch = createStorageFetch(() => 'conn-1'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse()); + const controller = new AbortController(); + + await storageFetch('/api/test', { signal: controller.signal }); + + const [, init] = fetchSpy.mock.calls[0]!; + expect(init?.signal).toBe(controller.signal); + }); +}); diff --git a/src/lib/storage/storage-fetch.ts b/src/lib/storage/storage-fetch.ts new file mode 100644 index 00000000..af8954e3 --- /dev/null +++ b/src/lib/storage/storage-fetch.ts @@ -0,0 +1,62 @@ +/** + * Centralised fetch wrapper for all storage API calls. + * + * Every storage endpoint requires the `x-storage-connection-id` header. + * This module eliminates the repeated pattern of: + * 1. Reading the connection ID from the store + * 2. Setting the header + * 3. Calling fetch + * 4. Mapping HTTP status to a StorageErrorCode + * + * Usage via the factory: + * const fetch_ = createStorageFetch(() => connectionStore.activeConnectionId); + */ + +import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; +import { StorageError } from '$lib/storage/errors.js'; + +/** + * Map an HTTP status code to a StorageError code. + * + * This consolidates the duplicated mapping that previously appeared in + * `state.svelte.ts`, `download.ts`, and `upload.ts`. + */ +export function mapStatusToCode(status: number): string { + if (status === 401) return 'not_connected'; + if (status === 403) return 'access_denied'; + if (status === 404) return 'not_found'; + if (status === 409) return 'conflict'; + if (status >= 500) return 'server_error'; + return 'unknown'; +} + +/** + * Create a `storageFetch` function bound to a connection-ID getter. + * + * @param getConnectionId A function that returns the active connection ID, + * or `null` when no connection is active. + * @returns A fetch-like function that automatically injects the connection + * header and throws `StorageError` on failure. + */ +export function createStorageFetch( + getConnectionId: () => string | null +): (path: string, init?: RequestInit) => Promise { + return async function storageFetch(path: string, init?: RequestInit): Promise { + const connectionId = getConnectionId(); + if (!connectionId) { + throw new StorageError('not_connected', 'No active storage connection'); + } + + const headers = new Headers(init?.headers); + headers.set(STORAGE_CONNECTION_ID_HEADER, connectionId); + + const response = await fetch(path, { ...init, headers }); + + if (!response.ok) { + const code = mapStatusToCode(response.status); + throw new StorageError(code, `Request failed with status ${response.status}`); + } + + return response; + }; +} diff --git a/src/lib/storage/tabs.svelte.spec.ts b/src/lib/storage/tabs.svelte.spec.ts new file mode 100644 index 00000000..b3b9431d --- /dev/null +++ b/src/lib/storage/tabs.svelte.spec.ts @@ -0,0 +1,596 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TabsState, type PersistedTabsState } from '$lib/storage/tabs.svelte.js'; +import { StorageState } from '$lib/storage/state.svelte.js'; +import { LS_TABS } from '$lib/storage/persistence.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeStorage(bucket = 'test-bucket', prefix = ''): StorageState { + const state = new StorageState({ connected: true }); + state.bucket = bucket; + state.prefix = prefix; + state.connectionHostname = 's3.example.com'; + state.objects = { objects: [], hasNextPage: false, currentPage: 1, pageSize: 25 }; + return state; +} + +function makeTabs( + storage: StorageState, + opts?: { persistEnabled?: boolean; connectionId?: string | null } +) { + const navigateToLocation = vi.fn(); + const replaceLocationUrl = vi.fn(); + const ts = new TabsState(storage, { + persistEnabled: opts?.persistEnabled ?? false, + connectionId: opts?.connectionId ?? null, + navigateToLocation, + replaceLocationUrl + }); + return { ts, navigateToLocation, replaceLocationUrl }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('TabsState', () => { + beforeEach(() => { + localStorage.clear(); + }); + + // ── ensureInitialTab ────────────────────────────────────────────────────── + + describe('ensureInitialTab', () => { + it('creates an initial tab from storage state', () => { + const storage = makeStorage('my-bucket', ''); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + expect(ts.tabs.length).toBe(1); + expect(ts.tabs[0].label).toBe('my-bucket'); + expect(ts.tabs[0].stub).toBe(false); + expect(ts.activeTabId).toBe(ts.tabs[0].id); + }); + + it('uses last path segment as label when prefix is set', () => { + const storage = makeStorage('bucket', 'reports/2024/'); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + expect(ts.tabs[0].label).toBe('2024'); + }); + + it('is a no-op when tabs already exist', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.ensureInitialTab(); + + expect(ts.tabs.length).toBe(1); + }); + }); + + // ── hasTabs ─────────────────────────────────────────────────────────────── + + describe('hasTabs', () => { + it('returns true with one tab', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + expect(ts.hasTabs).toBe(true); + }); + + it('returns true with two or more tabs', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + + expect(ts.hasTabs).toBe(true); + }); + }); + + // ── addTab ──────────────────────────────────────────────────────────────── + + describe('addTab', () => { + it('appends a tab at the current location', () => { + const storage = makeStorage('bucket', 'folder/'); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + + expect(ts.tabs.length).toBe(2); + expect(ts.activeTabId).toBe(ts.tabs[1].id); + expect(ts.tabs[1].label).toBe('folder'); + }); + + it('new tab is not a stub', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + + expect(ts.tabs[1].stub).toBe(false); + }); + + it('snapshot captures current storage state', () => { + const storage = makeStorage('bucket', 'data/'); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + storage.prefix = 'other/'; + ts.addTab(); + + expect(ts.tabs[1].snapshot.prefix).toBe('other/'); + }); + }); + + // ── switchTo ────────────────────────────────────────────────────────────── + + describe('switchTo', () => { + it('is a no-op when switching to the already-active tab', () => { + const storage = makeStorage(); + const { ts, replaceLocationUrl, navigateToLocation } = makeTabs(storage); + ts.ensureInitialTab(); + + ts.switchTo(ts.activeTabId!); + + expect(replaceLocationUrl).not.toHaveBeenCalled(); + expect(navigateToLocation).not.toHaveBeenCalled(); + }); + + it('navigates with replaceState for a non-stub tab', () => { + const storage = makeStorage('bucket', 'a/'); + const { ts, replaceLocationUrl } = makeTabs(storage); + ts.ensureInitialTab(); // tabs[0] = {prefix:'a/'}, active + + // Add a second tab at the same location, then navigate the second tab to 'b/' + ts.addTab(); // tabs[1] = {prefix:'a/'}, active = tabs[1] + storage.prefix = 'b/'; + ts.syncActiveTab(); // tabs[1].snapshot.prefix = 'b/' + + // Now: tabs[0]={prefix:'a/'}, tabs[1]={prefix:'b/'} (active) + ts.switchTo(ts.tabs[0].id); + + expect(storage.prefix).toBe('b/'); + expect(replaceLocationUrl).toHaveBeenCalledWith('s3.example.com', 'bucket', 'a/'); + }); + + it('calls navigateToLocation for a stub tab', () => { + const storage = makeStorage('bucket', 'a/'); + const { ts, navigateToLocation } = makeTabs(storage); + + const saved: PersistedTabsState = { + tabs: [ + { id: 'a', label: 'A', bucket: 'bucket', prefix: 'a/' }, + { id: 'b', label: 'B', bucket: 'bucket', prefix: 'b/' } + ], + activeTabId: 'a' + }; + ts.restorePersistedTabs(saved); + // tabs[0] gets markActiveTabLoaded() because storage matches 'a/' + // tabs[1] stays as a stub + + ts.switchTo(ts.tabs[1].id); + + expect(navigateToLocation).toHaveBeenCalledWith('s3.example.com', 'bucket', 'b/'); + }); + + it('sets activeTabId to the switched-to tab', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + const firstId = ts.tabs[0].id; + + ts.switchTo(firstId); + + expect(ts.activeTabId).toBe(firstId); + }); + + it('does not let a pending navigation overwrite a tab switched to in the meantime', () => { + const storage = makeStorage('first-bucket', 'first/'); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + const firstTabId = ts.activeTabId!; + ts.addTab(); + + ts.prepareActiveTabForNavigation('s3.example.com', 'second-bucket', 'second/'); + ts.switchTo(firstTabId); + + expect(ts.canSyncServerLocation('s3.example.com', 'second-bucket', 'second/')).toBe(false); + expect(storage.bucket).toBe('first-bucket'); + expect(storage.prefix).toBe('first/'); + }); + }); + + // ── closeTab ────────────────────────────────────────────────────────────── + + describe('closeTab', () => { + it('removes the tab from the list', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + const firstId = ts.tabs[0].id; + + ts.closeTab(firstId); + + expect(ts.tabs.length).toBe(1); + expect(ts.tabs.find((t) => t.id === firstId)).toBeUndefined(); + }); + + it('switches to an adjacent tab when the active tab is closed', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + const secondId = ts.tabs[1].id; + + ts.closeTab(secondId); // active tab closed + + expect(ts.activeTabId).toBe(ts.tabs[0].id); + }); + + it('does not close the only remaining tab', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + ts.closeTab(ts.tabs[0].id); + + expect(ts.tabs.length).toBe(1); + }); + }); + + // ── renameTab ───────────────────────────────────────────────────────────── + + describe('renameTab', () => { + it('renames the tab with the given id', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + ts.renameTab(ts.tabs[0].id, 'My Custom Name'); + + expect(ts.tabs[0].label).toBe('My Custom Name'); + }); + + it('is a no-op for an unknown id', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + ts.renameTab('nonexistent-id', 'New Name'); + + expect(ts.tabs[0].label).toBe('test-bucket'); + }); + }); + + // ── reorderTabs ─────────────────────────────────────────────────────────── + + describe('reorderTabs', () => { + it('moves a tab from one index to another', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + ts.addTab(); + const ids = ts.tabs.map((t) => t.id); + + ts.reorderTabs(0, 2); + + expect(ts.tabs[2].id).toBe(ids[0]); + expect(ts.tabs[0].id).toBe(ids[1]); + }); + + it('is a no-op when fromIdx === toIdx', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + const ids = ts.tabs.map((t) => t.id); + + ts.reorderTabs(0, 0); + + expect(ts.tabs.map((t) => t.id)).toEqual(ids); + }); + }); + + // ── syncActiveTab ───────────────────────────────────────────────────────── + + describe('syncActiveTab', () => { + it('keeps other tabs at their own bucket when the active tab changes bucket', () => { + const storage = makeStorage('bucket-a', ''); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.addTab(); + + storage.bucket = 'bucket-b'; + storage.prefix = ''; + ts.syncActiveTab(); + + expect(ts.tabs[0].snapshot.bucket).toBe('bucket-a'); + expect(ts.tabs[1].snapshot.bucket).toBe('bucket-b'); + }); + + it('updates snapshot bucket and prefix from current storage state', () => { + const storage = makeStorage('bucket', 'old/'); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + storage.prefix = 'new/'; + ts.syncActiveTab(); + + expect(ts.tabs[0].snapshot.prefix).toBe('new/'); + }); + + it('updates the auto-generated label to match new location', () => { + const storage = makeStorage('bucket', 'old/'); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + storage.prefix = 'new/'; + ts.syncActiveTab(); + + expect(ts.tabs[0].label).toBe('new'); + }); + + it('preserves a custom label that was manually set', () => { + const storage = makeStorage('bucket', 'folder/'); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + ts.renameTab(ts.tabs[0].id, 'My Custom'); + + storage.prefix = 'other/'; + ts.syncActiveTab(); + + expect(ts.tabs[0].label).toBe('My Custom'); + }); + }); + + // ── markActiveTabLoaded ─────────────────────────────────────────────────── + + describe('markActiveTabLoaded', () => { + it('clears the stub flag on the active tab', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + // Force it to be a stub + ts.tabs = [{ ...ts.tabs[0], stub: true }]; + + ts.markActiveTabLoaded(); + + expect(ts.tabs[0].stub).toBe(false); + }); + + it('is a no-op when the active tab is not a stub', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.ensureInitialTab(); + + // Should not throw or mutate + ts.markActiveTabLoaded(); + + expect(ts.tabs[0].stub).toBe(false); + }); + }); + + // ── persistence ─────────────────────────────────────────────────────────── + + describe('peekPersistedTabs', () => { + it('returns null when persistence is disabled', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage, { persistEnabled: false }); + + expect(ts.peekPersistedTabs()).toBeNull(); + }); + + it('returns null when nothing is saved in localStorage', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage, { persistEnabled: true }); + + expect(ts.peekPersistedTabs()).toBeNull(); + }); + + it('returns saved data after ensureInitialTab saves it', () => { + const storage = makeStorage('bucket', ''); + const { ts } = makeTabs(storage, { persistEnabled: true }); + ts.ensureInitialTab(); + + const peeked = ts.peekPersistedTabs(); + + expect(peeked).not.toBeNull(); + expect(peeked!.tabs[0].bucket).toBe('bucket'); + }); + + it('returns null for a different connectionId', () => { + const saved: PersistedTabsState = { + tabs: [{ id: '1', label: 'Bucket', bucket: 'bucket', prefix: '' }], + activeTabId: '1', + connectionId: 'conn-a' + }; + localStorage.setItem(LS_TABS, JSON.stringify(saved)); + + const storage = makeStorage(); + const { ts } = makeTabs(storage, { persistEnabled: true, connectionId: 'conn-b' }); + + expect(ts.peekPersistedTabs()).toBeNull(); + }); + + it('accepts saved data with no connectionId (backward compatibility)', () => { + const saved: PersistedTabsState = { + tabs: [{ id: '1', label: 'Bucket', bucket: 'bucket', prefix: '' }], + activeTabId: '1' + // connectionId absent intentionally + }; + localStorage.setItem(LS_TABS, JSON.stringify(saved)); + + const storage = makeStorage(); + const { ts } = makeTabs(storage, { persistEnabled: true, connectionId: 'conn-a' }); + + expect(ts.peekPersistedTabs()).not.toBeNull(); + }); + + it('accepts saved data when connectionIds match', () => { + const saved: PersistedTabsState = { + tabs: [{ id: '1', label: 'Bucket', bucket: 'bucket', prefix: '' }], + activeTabId: '1', + connectionId: 'conn-a' + }; + localStorage.setItem(LS_TABS, JSON.stringify(saved)); + + const storage = makeStorage(); + const { ts } = makeTabs(storage, { persistEnabled: true, connectionId: 'conn-a' }); + + expect(ts.peekPersistedTabs()).not.toBeNull(); + }); + }); + + describe('clearPersistedTabs', () => { + it('removes the entry from localStorage', () => { + const storage = makeStorage(); + const { ts } = makeTabs(storage, { persistEnabled: true }); + ts.ensureInitialTab(); + + ts.clearPersistedTabs(); + + expect(localStorage.getItem(LS_TABS)).toBeNull(); + }); + }); + + describe('ensureInitialTab persistence write', () => { + it('writes tab data to localStorage when persistEnabled', () => { + const storage = makeStorage('bucket', ''); + const { ts } = makeTabs(storage, { persistEnabled: true }); + ts.ensureInitialTab(); + + const raw = localStorage.getItem(LS_TABS); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!) as PersistedTabsState; + expect(parsed.tabs.length).toBe(1); + expect(parsed.tabs[0].bucket).toBe('bucket'); + }); + }); + + // ── restorePersistedTabs ────────────────────────────────────────────────── + + describe('restorePersistedTabs', () => { + it('creates stub tabs from saved data', () => { + const saved: PersistedTabsState = { + tabs: [ + { id: 'old1', label: 'Tab A', bucket: 'bucket', prefix: 'folder/' }, + { id: 'old2', label: 'Tab B', bucket: 'bucket', prefix: 'other/' } + ], + activeTabId: 'old2' + }; + const storage = makeStorage('bucket', 'folder/'); + const { ts } = makeTabs(storage); + ts.restorePersistedTabs(saved); + + expect(ts.tabs.length).toBe(2); + expect(ts.tabs[0].label).toBe('Tab A'); + expect(ts.tabs[1].label).toBe('Tab B'); + }); + + it('sets activeTabId to the saved active tab (by position)', () => { + const saved: PersistedTabsState = { + tabs: [ + { id: 'a', label: 'A', bucket: 'b', prefix: '' }, + { id: 'b', label: 'B', bucket: 'b', prefix: 'x/' } + ], + activeTabId: 'b' + }; + const storage = makeStorage('b', ''); + const { ts } = makeTabs(storage); + ts.restorePersistedTabs(saved); + + // activeTabId should correspond to the second tab (position index 1) + expect(ts.activeTabId).toBe(ts.tabs[1].id); + }); + + it('generates fresh UUIDs — does not reuse the saved ids', () => { + const saved: PersistedTabsState = { + tabs: [{ id: 'old-id', label: 'A', bucket: 'b', prefix: '' }], + activeTabId: 'old-id' + }; + const storage = makeStorage(); + const { ts } = makeTabs(storage); + ts.restorePersistedTabs(saved); + + expect(ts.tabs[0].id).not.toBe('old-id'); + }); + + it('marks active tab as loaded when storage is already at that location', () => { + const saved: PersistedTabsState = { + tabs: [{ id: '1', label: 'Bucket', bucket: 'test-bucket', prefix: '' }], + activeTabId: '1' + }; + const storage = makeStorage('test-bucket', ''); + const { ts } = makeTabs(storage); + ts.restorePersistedTabs(saved); + + expect(ts.tabs[0].stub).toBe(false); + }); + + it('leaves tab as stub when storage location differs from active tab', () => { + const saved: PersistedTabsState = { + tabs: [ + { id: 'a', label: 'A', bucket: 'bucket', prefix: 'a/' }, + { id: 'b', label: 'B', bucket: 'bucket', prefix: 'b/' } + ], + activeTabId: 'b' + }; + const storage = makeStorage('bucket', 'a/'); // at a/, but active tab is b/ + const { ts, navigateToLocation } = makeTabs(storage); + ts.restorePersistedTabs(saved); + + // Active tab (b/) is a stub and storage is at a/ → navigate is called + expect(navigateToLocation).toHaveBeenCalledWith('s3.example.com', 'bucket', 'b/'); + }); + }); + + // ── requestTabsRestore ──────────────────────────────────────────────────── + + describe('requestTabsRestore', () => { + it('triggers restore on the next ensureInitialTab call when persistEnabled', () => { + const saved: PersistedTabsState = { + tabs: [ + { id: 'a', label: 'A', bucket: 'b1', prefix: '' }, + { id: 'b', label: 'B', bucket: 'b2', prefix: '' } + ], + activeTabId: 'a' + }; + localStorage.setItem(LS_TABS, JSON.stringify(saved)); + + const storage = makeStorage('b1', ''); + const { ts } = makeTabs(storage, { persistEnabled: true }); + ts.ensureInitialTab(); + + // Should have restored 2 tabs rather than creating 1 fresh tab + expect(ts.tabs.length).toBe(2); + }); + + it('restores from localStorage on every ensureInitialTab when persistEnabled', () => { + const saved: PersistedTabsState = { + tabs: [ + { id: 'a', label: 'A', bucket: 'b1', prefix: '' }, + { id: 'b', label: 'B', bucket: 'b2', prefix: '' } + ], + activeTabId: 'a' + }; + localStorage.setItem(LS_TABS, JSON.stringify(saved)); + + // First TabsState restores from localStorage + const storage1 = makeStorage('b1', ''); + const { ts: ts1 } = makeTabs(storage1, { persistEnabled: true }); + ts1.ensureInitialTab(); + expect(ts1.tabs.length).toBe(2); + + // Second TabsState — persistence is still enabled and data exists, + // so it restores from localStorage again (handles page reloads) + const storage2 = makeStorage('b1', ''); + const { ts: ts2 } = makeTabs(storage2, { persistEnabled: true }); + ts2.ensureInitialTab(); + expect(ts2.tabs.length).toBe(2); + }); + }); +}); diff --git a/src/lib/storage/tabs.svelte.ts b/src/lib/storage/tabs.svelte.ts new file mode 100644 index 00000000..ef316391 --- /dev/null +++ b/src/lib/storage/tabs.svelte.ts @@ -0,0 +1,492 @@ +import { browser } from '$app/environment'; +import type { StoragePage } from '$lib/storage/types.js'; +import type { PageSize } from '$lib/types/pagination.js'; +import type { StorageState } from './state.svelte.js'; +import { LS_TABS } from './persistence.js'; +import { keyToName } from './utils.js'; + +// ── Tab data ───────────────────────────────────────────────────────────────── + +export interface TabSnapshot { + connection: string; + bucket: string; + prefix: string; + objects: StoragePage; + prevTokens: (string | null)[]; + pageSize: PageSize; + archiveKey: string | null; + archivePrefix: string; + archiveNestedPath: string | null; + previousS3Prefix: string; + archiveLoading: boolean; + archiveTooLarge: boolean; + /** Pre-computed auto-generated label for this snapshot (used by syncActiveTab + * to decide whether the label was manually renamed). */ + autoLabel: string; +} + +export interface Tab { + id: string; + label: string; + /** True when this tab was restored from persistence and has not yet loaded + * fresh data. Switching to a stub triggers a full navigation instead of a + * snapshot restore. */ + stub: boolean; + snapshot: TabSnapshot; +} + +// ── Persistence schema ──────────────────────────────────────────────────────── + +export interface PersistedTab { + id: string; + label: string; + bucket: string; + prefix: string; + connection?: string; +} + +export interface PersistedTabsState { + tabs: PersistedTab[]; + activeTabId: string; + /** Fingerprint of the connection that saved these tabs. + * Absent in data saved before this field was introduced (treated as a match + * for any connection to preserve backward compatibility). */ + connectionId?: string; +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +const EMPTY_PAGE: StoragePage = { objects: [], hasNextPage: false, currentPage: 1, pageSize: 25 }; + +// ── Tabs state ─────────────────────────────────────────────────────────────── + +export class TabsState { + tabs = $state([]); + activeTabId = $state(null); + + private storage: StorageState; + private pendingNavigation: { + tabId: string; + connection: string; + bucket: string; + prefix: string; + } | null = null; + private persistEnabled: boolean; + private connectionId: string | null; + private navigateToLocation: ((connection: string, bucket: string, prefix: string) => void) | null; + private replaceLocationUrl: ((connection: string, bucket: string, prefix: string) => void) | null; + + get hasTabs(): boolean { + return this.tabs.length > 0; + } + + constructor( + storage: StorageState, + options?: { + persistEnabled?: boolean; + connectionId?: string | null; + navigateToLocation?: (connection: string, bucket: string, prefix: string) => void; + replaceLocationUrl?: (connection: string, bucket: string, prefix: string) => void; + } + ) { + this.storage = storage; + this.persistEnabled = options?.persistEnabled ?? false; + this.connectionId = options?.connectionId ?? null; + this.navigateToLocation = options?.navigateToLocation ?? null; + this.replaceLocationUrl = options?.replaceLocationUrl ?? null; + } + + // ── Snapshot helpers ───────────────────────────────────────────────────── + + private captureSnapshot(): TabSnapshot { + return { + connection: this.storage.connectionHostname, + bucket: this.storage.bucket, + prefix: this.storage.prefix, + objects: this.storage.objects, + prevTokens: [...this.storage.prevTokens], + pageSize: this.storage.pageSize, + archiveKey: this.storage.archive.archiveKey, + archivePrefix: this.storage.archive.archivePrefix, + archiveNestedPath: this.storage.archive.nestedArchivePath ?? null, + previousS3Prefix: this.storage.archive._previousS3Prefix, + archiveLoading: this.storage.archive.archiveLoading, + archiveTooLarge: this.storage.archive.archiveTooLarge, + autoLabel: this.computeAutoLabel() + }; + } + + private computeAutoLabel(): string { + if (this.storage.archive.isInArchive && this.storage.archive.archiveKey) { + return this.archiveLabelFrom( + this.storage.archive.archiveKey, + this.storage.archive.archivePrefix, + this.storage.archive.nestedArchivePath ?? null + ); + } + return this.buildLabel(this.storage.bucket, this.storage.prefix); + } + + private restoreSnapshot(snapshot: TabSnapshot): void { + this.storage.bucket = snapshot.bucket; + this.storage.connectionHostname = snapshot.connection; + this.storage.prefix = snapshot.prefix; + this.storage.objects = snapshot.objects; + this.storage.prevTokens = [...snapshot.prevTokens]; + this.storage.pageSize = snapshot.pageSize; + this.storage.archive._restoreFullState({ + archiveKey: snapshot.archiveKey, + archivePrefix: snapshot.archivePrefix, + archiveNestedPath: snapshot.archiveNestedPath, + previousS3Prefix: snapshot.previousS3Prefix, + archiveLoading: snapshot.archiveLoading, + archiveTooLarge: snapshot.archiveTooLarge + }); + this.storage.loading = false; + this.storage.clearSelection(); + this.replaceLocationUrl?.(snapshot.connection, snapshot.bucket, snapshot.prefix); + } + + // ── Persistence ────────────────────────────────────────────────────────── + + private saveToPersistence(): void { + if (!this.persistEnabled || !browser) return; + const data: PersistedTabsState = { + tabs: this.tabs.map((t) => ({ + id: t.id, + label: t.label, + bucket: t.snapshot.bucket, + prefix: t.snapshot.prefix, + connection: t.snapshot.connection + })), + activeTabId: this.activeTabId ?? '', + connectionId: this.connectionId ?? undefined + }; + localStorage.setItem(LS_TABS, JSON.stringify(data)); + } + + /** Reads persisted tab state from localStorage without modifying any state. + * Returns null if nothing is saved, persistence is disabled, or the saved + * state belongs to a different connection. */ + peekPersistedTabs(): PersistedTabsState | null { + if (!this.persistEnabled || !browser) return null; + try { + const raw = localStorage.getItem(LS_TABS); + if (!raw) return null; + const data = JSON.parse(raw) as PersistedTabsState; + if (!Array.isArray(data.tabs) || data.tabs.length === 0) return null; + // If both sides have a connectionId and they don't match, this save belongs + // to a different connection — do not offer restore. + if (data.connectionId && this.connectionId && data.connectionId !== this.connectionId) { + return null; + } + return data; + } catch { + return null; + } + } + + /** Restores tabs from a previously peeked snapshot. Creates stub tabs for + * all locations and navigates to the active one. Each tab gets a fresh UUID + * to guarantee no key collisions with concurrently created tabs. */ + restorePersistedTabs(saved: PersistedTabsState): void { + // Generate a fresh UUID per tab slot so that any duplicate IDs that may + // exist in older persisted data (from the previous counter-based scheme) + // never collide in the rendered each block. + const activeIdx = saved.tabs.findIndex((pt) => pt.id === saved.activeTabId); + + this.tabs = saved.tabs.map((pt) => ({ + id: crypto.randomUUID(), + label: pt.label, + stub: true, + snapshot: { + connection: pt.connection ?? this.storage.connectionHostname, + bucket: pt.bucket, + prefix: pt.prefix, + objects: EMPTY_PAGE, + prevTokens: [], + pageSize: this.storage.pageSize, + archiveKey: null, + archivePrefix: '', + archiveNestedPath: null, + previousS3Prefix: '', + archiveLoading: false, + archiveTooLarge: false, + autoLabel: pt.label + } + })); + + const mappedActiveId = activeIdx >= 0 ? this.tabs[activeIdx].id : this.tabs[0].id; + this.activeTabId = mappedActiveId; + + // Persist immediately with the new UUIDs so the banner won't re-offer on + // the next visit to /storage. + this.saveToPersistence(); + + const activeTab = this.tabs.find((t) => t.id === mappedActiveId)!; + + if ( + this.storage.connectionHostname === activeTab.snapshot.connection && + this.storage.bucket === activeTab.snapshot.bucket && + this.storage.prefix === activeTab.snapshot.prefix + ) { + this.markActiveTabLoaded(); + } else if (this.navigateToLocation) { + this.navigateToLocation( + activeTab.snapshot.connection, + activeTab.snapshot.bucket, + activeTab.snapshot.prefix + ); + } else { + this.markActiveTabLoaded(); + } + } + + /** Removes the persisted tabs entry from localStorage. */ + clearPersistedTabs(): void { + if (browser) localStorage.removeItem(LS_TABS); + } + + // ── Tab operations ─────────────────────────────────────────────────────── + + /** Initialises tabs from the current storage state. If persistence is + * enabled, restores tabs from localStorage (handles both SPA navigation + * via the /storage banner flag and full page reloads). + * + * Tabs are only restored when the current storage bucket/prefix matches + * the saved active tab's location — otherwise the user navigated to a + * different location directly and stale tabs should not override it. */ + ensureInitialTab(): void { + if (this.tabs.length > 0) return; + + if (this.persistEnabled) { + const saved = this.peekPersistedTabs(); + if (saved && saved.tabs.length > 0) { + const activeIdx = saved.tabs.findIndex((pt) => pt.id === saved.activeTabId); + const activeTab = activeIdx >= 0 ? saved.tabs[activeIdx] : saved.tabs[0]; + if ( + activeTab && + this.storage.connectionHostname === + (activeTab.connection ?? this.storage.connectionHostname) && + this.storage.bucket === activeTab.bucket && + this.storage.prefix === activeTab.prefix + ) { + this.restorePersistedTabs(saved); + return; + } + this.clearPersistedTabs(); + } + } + + const id = crypto.randomUUID(); + const label = this.computeAutoLabel(); + this.tabs = [{ id, label, stub: false, snapshot: this.captureSnapshot() }]; + this.activeTabId = id; + this.saveToPersistence(); + } + + /** Marks the active tab as loaded with current storage data (clears stub). */ + markActiveTabLoaded(): void { + if (!this.activeTabId) return; + const idx = this.tabs.findIndex((t) => t.id === this.activeTabId); + if (idx === -1) return; + + const tab = this.tabs[idx]; + if (!tab.stub) return; + this.tabs = [ + ...this.tabs.slice(0, idx), + { ...tab, stub: false, snapshot: this.captureSnapshot() }, + ...this.tabs.slice(idx + 1) + ]; + } + + /** Updates the active tab's snapshot to reflect current storage state. */ + syncActiveTab(): void { + if (!this.activeTabId) return; + // The shared storage state still contains the previous location until the + // destination load completes, so it must not overwrite this tab's snapshot. + if (this.pendingNavigation?.tabId === this.activeTabId) return; + const idx = this.tabs.findIndex((t) => t.id === this.activeTabId); + if (idx === -1) return; + + const tab = this.tabs[idx]; + const updatedTab: Tab = { + ...tab, + stub: false, + snapshot: this.captureSnapshot(), + label: tab.label === tab.snapshot.autoLabel ? this.computeAutoLabel() : tab.label + }; + this.tabs = [...this.tabs.slice(0, idx), updatedTab, ...this.tabs.slice(idx + 1)]; + this.saveToPersistence(); + } + + /** Associates an in-flight route navigation with the current tab. */ + prepareActiveTabForNavigation(connection: string, bucket: string, prefix: string): void { + if (!this.activeTabId) return; + this.pendingNavigation = { tabId: this.activeTabId, connection, bucket, prefix }; + } + + /** Returns whether the current tab owns server data for this location. */ + canSyncServerLocation(connection: string, bucket: string, prefix: string): boolean { + if (!this.activeTabId) return true; + + if (this.pendingNavigation) { + const pending = this.pendingNavigation; + return ( + pending.tabId === this.activeTabId && + pending.connection === connection && + pending.bucket === bucket && + pending.prefix === prefix + ); + } + + const activeTab = this.tabs.find((tab) => tab.id === this.activeTabId); + return ( + activeTab?.snapshot.connection === connection && + activeTab.snapshot.bucket === bucket && + activeTab.snapshot.prefix === prefix + ); + } + + /** Marks the active tab's pending navigation as complete. */ + completeNavigation(): void { + if (this.pendingNavigation?.tabId === this.activeTabId) { + this.pendingNavigation = null; + } + } + + /** Adds a new tab at the current location and switches to it. */ + addTab(): void { + this.syncActiveTab(); + const id = crypto.randomUUID(); + const label = this.computeAutoLabel(); + const newTab: Tab = { id, label, stub: false, snapshot: this.captureSnapshot() }; + this.tabs = [...this.tabs, newTab]; + this.activeTabId = id; + this.saveToPersistence(); + } + + /** Switches to a tab by id. Route navigation keeps SvelteKit state in sync. */ + switchTo(id: string): void { + if (id === this.activeTabId) return; + const tab = this.tabs.find((t) => t.id === id); + if (!tab) return; + this.syncActiveTab(); + this.activeTabId = id; + this.pendingNavigation = null; + this.saveToPersistence(); + + if (tab.stub && this.navigateToLocation) { + this.navigateToLocation(tab.snapshot.connection, tab.snapshot.bucket, tab.snapshot.prefix); + } else if (this.replaceLocationUrl) { + this.replaceLocationUrl(tab.snapshot.connection, tab.snapshot.bucket, tab.snapshot.prefix); + } else { + this.restoreSnapshot(tab.snapshot); + } + } + + /** Closes a tab by id. If it's the active tab, switches to an adjacent one. */ + closeTab(id: string): void { + if (this.tabs.length <= 1) return; + const idx = this.tabs.findIndex((t) => t.id === id); + if (idx === -1) return; + + const newTabs = this.tabs.filter((t) => t.id !== id); + this.tabs = newTabs; + + if (id === this.activeTabId) { + const newIdx = Math.min(idx, newTabs.length - 1); + + const nextTab = newTabs[newIdx]; + this.activeTabId = nextTab.id; + this.pendingNavigation = null; + if (nextTab.stub && this.navigateToLocation) { + this.navigateToLocation( + nextTab.snapshot.connection, + nextTab.snapshot.bucket, + nextTab.snapshot.prefix + ); + } else if (this.replaceLocationUrl) { + this.replaceLocationUrl( + nextTab.snapshot.connection, + nextTab.snapshot.bucket, + nextTab.snapshot.prefix + ); + } else { + this.restoreSnapshot(nextTab.snapshot); + } + } + + this.saveToPersistence(); + } + + /** Renames a tab. */ + renameTab(id: string, newLabel: string): void { + const idx = this.tabs.findIndex((t) => t.id === id); + if (idx === -1) return; + + const tab = this.tabs[idx]; + this.tabs = [ + ...this.tabs.slice(0, idx), + { ...tab, label: newLabel }, + ...this.tabs.slice(idx + 1) + ]; + this.saveToPersistence(); + } + + /** Reorders tabs by moving from one index to another. */ + reorderTabs(fromIdx: number, toIdx: number): void { + if (fromIdx === toIdx) return; + const copy = [...this.tabs]; + const [moved] = copy.splice(fromIdx, 1); + copy.splice(toIdx, 0, moved); + this.tabs = copy; + this.saveToPersistence(); + } + + /** Marks all non-active tabs whose snapshot prefix matches the given prefix + * as stubs so that switching to them triggers a fresh server fetch. Called + * after file operations (move, delete, rename) that change a directory's + * contents. */ + setStalePrefix(prefix: string): void { + let changed = false; + const next = this.tabs.map((tab) => { + if (tab.id === this.activeTabId) return tab; + if (tab.snapshot.prefix === prefix && !tab.stub) { + changed = true; + return { ...tab, stub: true }; + } + return tab; + }); + if (changed) { + this.tabs = next; + this.saveToPersistence(); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private buildLabel(bucket: string, prefix: string): string { + if (!prefix) return bucket; + const parts = prefix.replace(/\/$/, '').split('/'); + return parts[parts.length - 1]; + } + + private archiveLabelFrom( + archiveKey: string | null, + archivePrefix: string, + archiveNestedPath: string | null + ): string { + if (archivePrefix) { + const parts = archivePrefix.replace(/\/$/, '').split('/'); + return parts[parts.length - 1]; + } + if (archiveNestedPath) { + return keyToName(archiveNestedPath); + } + if (archiveKey) { + return keyToName(archiveKey); + } + return ''; + } +} diff --git a/src/lib/storage/types.ts b/src/lib/storage/types.ts index 0864e988..44116d12 100644 --- a/src/lib/storage/types.ts +++ b/src/lib/storage/types.ts @@ -1,11 +1,56 @@ +import type { Component } from 'svelte'; + // ── Modal types ───────────────────────────────────────────────────────────── -export type ModalType = 'delete' | 'preview' | 'upload'; +export type ModalType = + | 'delete' + | 'preview' + | 'upload' + | 'details' + | 'rename' + | 'confirm-move' + | 'resolve-conflicts' + | 'create'; export interface ModalPayloads { delete: { keys: string[] }; - preview: { key: string }; + preview: { + key: string; + archiveKey?: string; + archivePath?: string; + nestedArchivePath?: string; + }; upload: { bucket: string; prefix: string }; + details: { + type: 'file' | 'directory' | 'bucket'; + bucket: string; + key?: string; + prefix?: string; + }; + rename: { key: string }; + 'confirm-move': { + keys: string[]; + destPrefix: string; + /** Per-item metadata for display in the confirmation dialog. */ + items: Array<{ key: string; name: string; isDirectory: boolean; size?: number }>; + }; + 'resolve-conflicts': { + /** Conflict entries to resolve. */ + entries: Array<{ + id: string; + originalName: string; + conflict: boolean; + resolution: 'replace' | 'skip' | 'rename' | null; + customName: string; + renameState: 'idle' | 'editing' | 'checking' | 'ok' | 'conflict'; + }>; + bucket: string; + /** Destination prefix for rename-conflict checking. */ + destPrefix: string; + /** Optional: label for the confirm button (e.g. "Paste" or "Move"). */ + confirmLabel?: string; + }; + create: { type: 'file' | 'folder' }; } export type ActiveModal = { @@ -14,10 +59,20 @@ export type ActiveModal = { // ── Context menu ───────────────────────────────────────────────────────────── +export interface ContextMenuAction { + key: string; + icon: Component; + label: string; + disabled?: boolean; + hidden?: boolean; + class?: string; +} + export interface ContextMenuState { x: number; y: number; - key: string; + /** The item key that was right-clicked, or undefined for empty-space context menu. */ + key?: string; } // ── Navigation ─────────────────────────────────────────────────────────────── @@ -38,7 +93,60 @@ export type ActionName = | 'pin' | 'unpin' | 'copy-filename' - | 'copy-path'; + | 'copy-path' + | 'details' + | 'cut' + | 'copy' + | 'paste' + | 'rename' + | 'create-file' + | 'create-folder'; + +// ── Clipboard state (cut / copy) ──────────────────────────────────────────── + +/** Tracks items stored in the virtual clipboard for cut/copy + paste operations. */ +export interface ClipboardData { + /** 'cut' items are rendered shaded; 'copy' items are not. */ + action: 'cut' | 'copy'; + /** S3 keys of the items in the clipboard. */ + keys: string[]; + /** Bucket the items belong to. */ + sourceBucket: string; + /** Prefix where the items were cut from (used to invalidate source tabs). */ + sourcePrefix: string; + /** File sizes keyed by S3 key (for recent files tracking). */ + fileSizes: Record; +} + +// ── Operations (paste / move / rename progress tracking) ───────────────────── + +export type OperationStatus = 'running' | 'done' | 'error' | 'cancelled' | 'interrupted'; + +export type OperationType = 'paste' | 'move' | 'rename' | 'delete'; + +export interface StorageOperation { + id: string; + label: string; + status: OperationStatus; + type: OperationType; + itemCount: number; + completedCount: number; + errorMessage?: string; + startedAt: number; + completedAt?: number; + /** Destination bucket/path for paste, move, and rename operations. */ + destPath?: string; + /** Source file names being processed (shown while running). */ + sourceNames?: string[]; + /** Total bytes across all items in this operation. */ + totalBytes: number; + /** Bytes transferred so far (sum of completed items). */ + completedBytes: number; + /** Name of the file currently being transferred. */ + currentFileName?: string; + /** Server-side job IDs per file for recovering results after reload. */ + fileJobIds?: string[]; +} // ── Storage locations ──────────────────────────────────────────────────────── @@ -86,6 +194,9 @@ export interface StorageMetadata { contentType: string | undefined; etag: string | undefined; customMetadata: Record | undefined; + versionId?: string | undefined; + storageClass?: string | undefined; + isDeleteMarker?: boolean; } /** A page of listed objects from a storage provider. */ @@ -104,3 +215,37 @@ export interface StoragePage { export interface DeleteObjectsResult { failed: Array<{ key: string; code?: string; message?: string }>; } + +// ── Archive navigation ─────────────────────────────────────────────────────── + +export const ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz', '.tgz', '.tar', '.rar', '.7z'] as const; + +export type ArchiveFormat = (typeof ARCHIVE_EXTENSIONS)[number] extends `${string}${infer F}` + ? F + : string; + +/** A single entry inside an archive (file or directory). */ +export interface ArchiveEntry { + key: string; + size: number; + lastModified: Date; + isDirectory: boolean; +} + +/** Response from the archive listing API. */ +export interface ArchiveListingResponse { + entries: ArchiveEntry[]; + hasMore: boolean; + /** When true, the archive was too large to open for preview. */ + tooLarge?: boolean; +} + +/** State when browsing inside an archive. */ +export interface ArchiveContext { + /** S3 key of the archive file being browsed. */ + archiveKey: string; + /** Virtual path within the archive (empty string = archive root). */ + archivePrefix: string; + /** The S3 prefix the user was at before entering the archive. */ + previousS3Prefix: string; +} diff --git a/src/lib/storage/upload.ts b/src/lib/storage/upload.ts index f64199af..ff349560 100644 --- a/src/lib/storage/upload.ts +++ b/src/lib/storage/upload.ts @@ -9,46 +9,14 @@ * supported in Firefox. * - The file is sent as the raw request body — no base64 or multipart encoding. * The server streams it directly to S3, preserving binary integrity. - * - The connection config is passed via the `X-Storage-Connection` header - * (base64-encoded JSON), read by the server from the client's localStorage. + * - The active connection UUID is passed via the `x-storage-connection-id` header. */ -import { STORAGE_CONNECTION_HEADER } from '$lib/storage/connection-storage.js'; +import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; +import { StorageError, type StorageErrorCode } from '$lib/storage/errors.js'; +import { createStorageFetch, mapStatusToCode } from '$lib/storage/storage-fetch.js'; -export type UploadErrorCode = - | 'not_connected' - | 'access_denied' - | 'no_such_bucket' - | 'invalid_part' - | 'server_error' - | 'unknown'; - -export class UploadError extends Error { - constructor( - public readonly code: UploadErrorCode, - message: string - ) { - super(message); - this.name = 'UploadError'; - } -} - -function buildUploadUrl(bucket: string, key: string): string { - return `/api/storage/upload?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`; -} - -function buildDownloadUrl(bucket: string, key: string): string { - return `/api/storage/download?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`; -} - -function mapStatusToUploadCode(status: number): UploadErrorCode { - if (status === 403) return 'access_denied'; - if (status === 404) return 'no_such_bucket'; - if (status === 400) return 'invalid_part'; - if (status === 401) return 'not_connected'; - if (status >= 500) return 'server_error'; - return 'unknown'; -} +export type UploadErrorCode = StorageErrorCode; /** * Check whether an object with the given key already exists in the bucket. @@ -60,18 +28,20 @@ function mapStatusToUploadCode(status: number): UploadErrorCode { export async function checkObjectExists( bucket: string, key: string, - connectionHeader: string + connectionId: string ): Promise { - const url = buildDownloadUrl(bucket, key); - const res = await fetch(url, { - method: 'HEAD', - headers: { [STORAGE_CONNECTION_HEADER]: connectionHeader } - }); - if (res.status === 200) return true; - if (res.status === 404) return false; - if (res.status === 401) throw new UploadError('not_connected', 'Not connected'); - if (res.status === 403) throw new UploadError('access_denied', 'Access denied'); - throw new UploadError('server_error', `Unexpected status ${res.status}`); + const fetch_ = createStorageFetch(() => connectionId); + try { + const res = await fetch_( + `/api/storage/download?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`, + { + method: 'HEAD' + } + ); + return res.ok; + } catch { + return false; + } } /** @@ -89,11 +59,11 @@ export function uploadFile( key: string, file: File, onProgress: (pct: number) => void, - connectionHeader: string + connectionId: string ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - const url = buildUploadUrl(bucket, key); + const url = `/api/storage/upload?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`; xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { @@ -107,21 +77,21 @@ export function uploadFile( resolve(); return; } - const code = mapStatusToUploadCode(xhr.status); - reject(new UploadError(code, `Upload failed with status ${xhr.status}`)); + const code = mapStatusToCode(xhr.status); + reject(new StorageError(code, `Upload failed with status ${xhr.status}`)); }); xhr.addEventListener('error', () => { - reject(new UploadError('server_error', 'Network error during upload')); + reject(new StorageError('server_error', 'Network error during upload')); }); xhr.addEventListener('abort', () => { - reject(new UploadError('unknown', 'Upload aborted')); + reject(new StorageError('unknown', 'Upload aborted')); }); xhr.open('POST', url); xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); - xhr.setRequestHeader(STORAGE_CONNECTION_HEADER, connectionHeader); + xhr.setRequestHeader(STORAGE_CONNECTION_ID_HEADER, connectionId); xhr.send(file); }); } diff --git a/src/lib/storage/utils.spec.ts b/src/lib/storage/utils.spec.ts new file mode 100644 index 00000000..537db12c --- /dev/null +++ b/src/lib/storage/utils.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fileIconKind, keyToName, formatDate, isArchiveExtension } from './utils.js'; + +vi.mock('$lib/paraglide/runtime.js', () => ({ + getLocale: () => 'en-GB' +})); + +describe('fileIconKind', () => { + it('returns "document" for undefined content type', () => { + expect(fileIconKind(undefined)).toBe('document'); + }); + + it('returns "image" for image types', () => { + expect(fileIconKind('image/png')).toBe('image'); + expect(fileIconKind('image/jpeg')).toBe('image'); + expect(fileIconKind('image/svg+xml')).toBe('image'); + }); + + it('returns "pdf" for application/pdf', () => { + expect(fileIconKind('application/pdf')).toBe('pdf'); + }); + + it('returns "code" for code types', () => { + expect(fileIconKind('application/json')).toBe('code'); + expect(fileIconKind('text/html')).toBe('code'); + expect(fileIconKind('text/css')).toBe('code'); + expect(fileIconKind('application/javascript')).toBe('code'); + expect(fileIconKind('text/javascript')).toBe('code'); + expect(fileIconKind('text/markdown')).toBe('code'); + }); + + it('returns "archive" for archive types', () => { + expect(fileIconKind('application/zip')).toBe('archive'); + expect(fileIconKind('application/gzip')).toBe('archive'); + expect(fileIconKind('application/x-tar')).toBe('archive'); + expect(fileIconKind('application/x-gzip')).toBe('archive'); + }); + + it('returns "text" for text types', () => { + expect(fileIconKind('text/plain')).toBe('text'); + expect(fileIconKind('text/csv')).toBe('text'); + }); + + it('returns "document" for unknown types', () => { + expect(fileIconKind('application/octet-stream')).toBe('document'); + expect(fileIconKind('video/mp4')).toBe('document'); + }); +}); + +describe('keyToName', () => { + it('extracts filename from a key', () => { + expect(keyToName('path/to/file.txt')).toBe('file.txt'); + }); + + it('handles single segment', () => { + expect(keyToName('file.txt')).toBe('file.txt'); + }); + + it('strips trailing slash for directory keys', () => { + expect(keyToName('path/to/dir/')).toBe('dir'); + }); + + it('returns the key itself for root-level keys', () => { + expect(keyToName('rootfile')).toBe('rootfile'); + }); +}); + +describe('formatDate', () => { + it('formats a date in en-GB short format', () => { + const date = new Date('2024-03-15T10:00:00Z'); + expect(formatDate(date)).toBe('15 Mar 2024'); + }); +}); + +describe('isArchiveExtension', () => { + it('returns true for known archive extensions', () => { + expect(isArchiveExtension('file.zip')).toBe(true); + expect(isArchiveExtension('file.tar.gz')).toBe(true); + expect(isArchiveExtension('file.tgz')).toBe(true); + expect(isArchiveExtension('file.tar')).toBe(true); + expect(isArchiveExtension('file.rar')).toBe(true); + expect(isArchiveExtension('file.7z')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(isArchiveExtension('file.ZIP')).toBe(true); + expect(isArchiveExtension('file.TAR.GZ')).toBe(true); + }); + + it('returns false for non-archive extensions', () => { + expect(isArchiveExtension('file.txt')).toBe(false); + expect(isArchiveExtension('file.pdf')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isArchiveExtension('')).toBe(false); + }); +}); diff --git a/src/lib/storage/utils.ts b/src/lib/storage/utils.ts index 5eaf4539..b7b44769 100644 --- a/src/lib/storage/utils.ts +++ b/src/lib/storage/utils.ts @@ -1,6 +1,7 @@ import prettyBytes from 'pretty-bytes'; import type { Options } from 'pretty-bytes'; import { getLocale } from '$lib/paraglide/runtime.js'; +import { ARCHIVE_EXTENSIONS } from './types.js'; export type FileIconKind = 'image' | 'pdf' | 'code' | 'archive' | 'text' | 'document'; @@ -13,7 +14,12 @@ const CODE_TYPES = new Set([ 'text/markdown' ]); -const ARCHIVE_TYPES = new Set(['application/gzip', 'application/zip', 'application/x-tar']); +const ARCHIVE_TYPES = new Set([ + 'application/gzip', + 'application/zip', + 'application/x-tar', + 'application/x-gzip' +]); export function fileIconKind(contentType: string | undefined): FileIconKind { if (!contentType) return 'document'; @@ -56,3 +62,9 @@ export function formatFileSize( ): string { return prettyBytes(bytes, { locale, ...options }); } + +/** Check if a filename/path has a navigable archive extension. */ +export function isArchiveExtension(filename: string): boolean { + const lower = filename.toLowerCase(); + return ARCHIVE_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} diff --git a/src/lib/stores/tab-store.svelte.spec.ts b/src/lib/stores/tab-store.svelte.spec.ts new file mode 100644 index 00000000..b55340f2 --- /dev/null +++ b/src/lib/stores/tab-store.svelte.spec.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { TabState } from './tab-store.svelte.js'; + +vi.mock('$app/environment', () => ({ browser: true })); +vi.mock('$lib/paraglide/messages.js', () => ({ trino_tab_default_name: () => 'Query' })); + +interface TabStoreApi { + tabs: TabState[]; + activeTabId: string; + activeTab: TabState; + maxTabs: number; + persistError: boolean; + createTab: () => TabState | null; + closeTab: (id: string) => void; + switchTab: (id: string) => void; + reorderTabs: (fromIndex: number, toIndex: number) => void; + updateSql: (id: string, sql: string) => void; + renameTab: (id: string, label: string | null) => void; + getTabLabel: (tab: TabState) => string; +} + +let store: TabStoreApi; +let uuidCounter = 0; + +function resetStore() { + localStorage.clear(); + while (store.tabs.length > 1) { + store.closeTab(store.tabs[store.tabs.length - 1].id); + } + if (store.tabs.length === 1) { + store.updateSql(store.tabs[0].id, 'SELECT 1'); + store.renameTab(store.tabs[0].id, null); + } +} + +function makeCryptoStub() { + return { randomUUID: () => `uuid-${++uuidCounter}` }; +} + +beforeEach(async () => { + vi.stubGlobal('crypto', makeCryptoStub()); + + if (!store) { + vi.resetModules(); + const mod = await import('./tab-store.svelte.js'); + store = mod.tabStore as unknown as TabStoreApi; + } else { + resetStore(); + } +}); + +afterEach(() => { + vi.unstubAllGlobals(); + // Clean up after tests that create 8 tabs (maxTabs). + while (store.tabs.length > 1) { + store.closeTab(store.tabs[store.tabs.length - 1].id); + } +}); + +describe('tab-store', () => { + it('creates a default tab with SELECT 1', () => { + expect(store.tabs.length).toBe(1); + expect(store.tabs[0].sql).toBe('SELECT 1'); + expect(store.tabs[0].label).toBeNull(); + }); + + it('sets activeTabId to the default tab', () => { + expect(store.activeTabId).toBe(store.tabs[0].id); + }); + + it('derives activeTab from activeTabId', () => { + expect(store.activeTab).toBe(store.tabs[0]); + }); + + it('createTab adds a new tab and switches to it', () => { + const tab = store.createTab(); + expect(tab).not.toBeNull(); + expect(store.tabs.length).toBe(2); + expect(store.activeTabId).toBe(tab!.id); + }); + + it('createTab returns null when max tabs reached', () => { + for (let i = 1; i < store.maxTabs; i++) { + store.createTab(); + } + expect(store.createTab()).toBeNull(); + expect(store.tabs.length).toBe(store.maxTabs); + // Clean up to 1 tab so subsequent tests don't inherit max state. + while (store.tabs.length > 1) { + store.closeTab(store.tabs[store.tabs.length - 1].id); + } + }); + + it('closeTab removes a non-active tab without switching', () => { + expect(store.tabs.length).toBe(1); + const originalId = store.tabs[0].id; + store.createTab(); + expect(store.tabs.length).toBe(2); + const activeBefore = store.activeTabId; + store.closeTab(originalId); + expect(store.tabs.find((t) => t.id === originalId)).toBeUndefined(); + expect(store.activeTabId).toBe(activeBefore); + }); + + it('closeTab switches to adjacent tab when closing active', () => { + store.createTab(); + store.createTab(); + store.switchTab(store.tabs[1].id); + store.closeTab(store.tabs[1].id); + expect(store.tabs.length).toBe(2); + expect(store.tabs.some((t) => t.id === store.activeTabId)).toBe(true); + }); + + it('does not close the last remaining tab', () => { + const tabId = store.tabs[0].id; + store.closeTab(tabId); + expect(store.tabs.length).toBe(1); + expect(store.tabs[0].id).toBe(tabId); + }); + + it('closeTab with unknown id is a no-op', () => { + store.closeTab('non-existent'); + expect(store.tabs.length).toBe(1); + }); + + it('switchTab changes active tab', () => { + store.createTab(); + store.createTab(); + const targetId = store.tabs[0].id; + store.switchTab(targetId); + expect(store.activeTabId).toBe(targetId); + }); + + it('switchTab ignores unknown id', () => { + const current = store.activeTabId; + store.switchTab('non-existent'); + expect(store.activeTabId).toBe(current); + }); + + it('reorderTabs moves a tab backward', () => { + store.createTab(); + store.createTab(); + const idsBefore = store.tabs.map((t) => t.id); + store.reorderTabs(0, 2); + const idsAfter = store.tabs.map((t) => t.id); + expect(idsAfter[0]).toBe(idsBefore[1]); + expect(idsAfter[1]).toBe(idsBefore[2]); + expect(idsAfter[2]).toBe(idsBefore[0]); + }); + + it('reorderTabs moves a tab forward', () => { + store.createTab(); + store.createTab(); + const idsBefore = store.tabs.map((t) => t.id); + store.reorderTabs(2, 0); + const idsAfter = store.tabs.map((t) => t.id); + expect(idsAfter[0]).toBe(idsBefore[2]); + expect(idsAfter[1]).toBe(idsBefore[0]); + expect(idsAfter[2]).toBe(idsBefore[1]); + }); + + it('reorderTabs same index is no-op', () => { + store.createTab(); + const idsBefore = store.tabs.map((t) => t.id); + store.reorderTabs(1, 1); + expect(store.tabs.map((t) => t.id)).toEqual(idsBefore); + }); + + it('reorderTabs out of bounds is no-op', () => { + const idsBefore = store.tabs.map((t) => t.id); + store.reorderTabs(-1, 0); + expect(store.tabs.map((t) => t.id)).toEqual(idsBefore); + }); + + it('updateSql updates sql immediately', () => { + store.updateSql(store.tabs[0].id, 'SELECT 2'); + expect(store.tabs[0].sql).toBe('SELECT 2'); + }); + + it('updateSql clamps to MAX_SQL_LENGTH', () => { + const longSql = 'x'.repeat(300_000); + store.updateSql(store.tabs[0].id, longSql); + expect(store.tabs[0].sql.length).toBe(250_000); + }); + + it('updateSql ignores unknown tab id', () => { + store.updateSql('non-existent', 'SELECT 2'); + expect(store.tabs[0].sql).toBe('SELECT 1'); + }); + + it('renameTab sets a custom label', () => { + store.renameTab(store.tabs[0].id, 'My Query'); + expect(store.tabs[0].label).toBe('My Query'); + }); + + it('renameTab with empty string clears label to null', () => { + store.renameTab(store.tabs[0].id, 'My Query'); + store.renameTab(store.tabs[0].id, ''); + expect(store.tabs[0].label).toBeNull(); + }); + + it('renameTab with whitespace clears label to null', () => { + store.renameTab(store.tabs[0].id, ' '); + expect(store.tabs[0].label).toBeNull(); + }); + + it('getTabLabel returns custom label when set', () => { + store.renameTab(store.tabs[0].id, 'Custom'); + expect(store.getTabLabel(store.tabs[0])).toBe('Custom'); + }); + + it('getTabLabel returns paraglide default when label is null', () => { + expect(store.getTabLabel(store.tabs[0])).toBe('Query'); + }); + + it('persistError is false after normal operations', () => { + store.createTab(); + store.updateSql(store.tabs[0].id, 'SELECT 2'); + expect(store.persistError).toBe(false); + }); + + it('persists tabs index to localStorage on create', () => { + store.createTab(); + const raw = localStorage.getItem('trino_tabs_index'); + expect(raw).not.toBeNull(); + const index = JSON.parse(raw!); + expect(Array.isArray(index.tabs)).toBe(true); + expect(index.tabs.length).toBe(2); + }); + + it('persists tab SQL to localStorage after debounce', async () => { + const key = `trino_tab_${store.tabs[0].id}`; + store.updateSql(store.tabs[0].id, 'SELECT 999'); + await new Promise((r) => setTimeout(r, 600)); + expect(localStorage.getItem(key)).toBe('SELECT 999'); + }); + + it('stores a complete index entry in localStorage after operations', () => { + const tab = store.createTab()!; + const raw = localStorage.getItem('trino_tabs_index'); + expect(raw).not.toBeNull(); + const index = JSON.parse(raw!); + expect(Array.isArray(index.tabs)).toBe(true); + expect(index.tabs.length).toBe(2); + expect(index.tabs.some((t: { id: string }) => t.id === tab.id)).toBe(true); + }); + + it('handles multiple closeTab and createTab cycles', () => { + store.createTab(); + store.createTab(); + expect(store.tabs.length).toBe(3); + + store.closeTab(store.tabs[1].id); + store.closeTab(store.tabs[1].id); + expect(store.tabs.length).toBe(1); + + store.createTab(); + expect(store.tabs.length).toBe(2); + }); +}); diff --git a/src/lib/stores/tab-store.svelte.ts b/src/lib/stores/tab-store.svelte.ts index 68edbbda..3e091ecc 100644 --- a/src/lib/stores/tab-store.svelte.ts +++ b/src/lib/stores/tab-store.svelte.ts @@ -201,6 +201,7 @@ function createTabStore() { if (activeTabId === id) { const newIndex = Math.min(index, tabs.length - 1); + activeTabId = tabs[newIndex].id; } saveIndex(); diff --git a/src/lib/stores/toast.svelte.spec.ts b/src/lib/stores/toast.svelte.spec.ts new file mode 100644 index 00000000..333db902 --- /dev/null +++ b/src/lib/stores/toast.svelte.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { toasts, addToast, removeToast } from './toast.svelte.js'; + +beforeEach(() => { + toasts.length = 0; +}); + +describe('addToast', () => { + it('adds a toast with the given properties', () => { + addToast('info', 'Hello world'); + expect(toasts).toHaveLength(1); + expect(toasts[0]).toMatchObject({ type: 'info', message: 'Hello world' }); + expect(typeof toasts[0].id).toBe('string'); + }); + + it('returns a unique id', () => { + const id1 = addToast('info', 'First'); + const id2 = addToast('success', 'Second'); + expect(id1).not.toBe(id2); + }); + + it('removes toast after duration', async () => { + vi.useFakeTimers(); + addToast('info', 'Timed', 100); + expect(toasts).toHaveLength(1); + vi.advanceTimersByTime(100); + expect(toasts).toHaveLength(0); + vi.useRealTimers(); + }); + + it('does not auto-remove when duration is 0', async () => { + vi.useFakeTimers(); + addToast('info', 'Persistent', 0); + vi.advanceTimersByTime(999999); + expect(toasts).toHaveLength(1); + vi.useRealTimers(); + }); + + it('stores actions when provided', () => { + const action = { label: 'Undo', onClick: () => {} }; + addToast('warning', 'With action', 5000, [action]); + expect(toasts[0].actions).toEqual([action]); + }); +}); + +describe('removeToast', () => { + it('removes a toast by id', () => { + const id = addToast('error', 'Remove me'); + expect(toasts).toHaveLength(1); + removeToast(id); + expect(toasts).toHaveLength(0); + }); + + it('does nothing when id does not exist', () => { + addToast('info', 'Stay'); + removeToast('non-existent-id'); + expect(toasts).toHaveLength(1); + }); +}); + +describe('toasts reactivity', () => { + it('starts empty', () => { + expect(toasts).toEqual([]); + }); + + it('toast order is LIFO (push order)', () => { + addToast('info', 'First'); + addToast('success', 'Second'); + expect(toasts[0].message).toBe('First'); + expect(toasts[1].message).toBe('Second'); + }); +}); diff --git a/src/lib/stores/toast.svelte.ts b/src/lib/stores/toast.svelte.ts index 7a405679..3880ed0b 100644 --- a/src/lib/stores/toast.svelte.ts +++ b/src/lib/stores/toast.svelte.ts @@ -1,19 +1,31 @@ export type ToastType = 'info' | 'success' | 'warning' | 'error'; +export interface ToastAction { + label: string; + onClick: () => void; +} + interface Toast { id: string; type: ToastType; message: string; + actions?: ToastAction[]; } export const toasts = $state([]); -export function addToast(type: ToastType, message: string, durationMs = 5000): void { +export function addToast( + type: ToastType, + message: string, + durationMs = 5000, + actions?: ToastAction[] +): string { const id = crypto.randomUUID(); - toasts.push({ id, type, message }); + toasts.push({ id, type, message, actions }); if (durationMs > 0) { setTimeout(() => removeToast(id), durationMs); } + return id; } export function removeToast(id: string): void { diff --git a/src/lib/test-utils/mock-logger.ts b/src/lib/test-utils/mock-logger.ts new file mode 100644 index 00000000..e585ae3c --- /dev/null +++ b/src/lib/test-utils/mock-logger.ts @@ -0,0 +1,14 @@ +import { vi } from 'vitest'; + +const childLogger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + child: vi.fn() +}; + +childLogger.child.mockReturnValue(childLogger); + +export const logger = { child: vi.fn(() => childLogger) }; diff --git a/src/lib/types/navigation.ts b/src/lib/types/navigation.ts index 0b534eb5..66a65928 100644 --- a/src/lib/types/navigation.ts +++ b/src/lib/types/navigation.ts @@ -1,5 +1,5 @@ -import type { Pathname } from '$app/types'; import type { Component } from 'svelte'; +import type { Pathname } from '$app/types'; export type NavItem = { label: string; diff --git a/src/routes/(app)/api/storage/archive/extract/+server.ts b/src/routes/(app)/api/storage/archive/extract/+server.ts new file mode 100644 index 00000000..fe4019aa --- /dev/null +++ b/src/routes/(app)/api/storage/archive/extract/+server.ts @@ -0,0 +1,111 @@ +import { error } from '@sveltejs/kit'; +import { extractArchiveEntry, getArchiveFormat } from '$lib/server/storage/archive.js'; +import { getProvider } from '$lib/server/storage/utils.js'; +import { withStorageHttpErrors } from '$lib/server/storage/wrap-provider.js'; +import { archivePreviewMaxBytes } from '$lib/server/feature-flags.js'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/storage/archive/extract?bucket=&key=&path=&nestedArchivePath= + * + * Extracts a single file from an archive and returns it as a stream. + * Supports nested archives: when `nestedArchivePath` is set, the file is + * extracted from within a nested archive inside the outer one. + * + * The archive is downloaded from S3 once and cached server-side, so multiple + * extractions from the same archive share a single S3 transfer. + * + * Returns 404 if the internal path does not exist in the archive or if the + * archive exceeds the configured preview size limit. + * Returns 400 if the archive is not a supported format. + * + * The connection config is parsed and validated by the `handleStorageConnection` + * middleware in hooks.server.ts before this handler runs. + */ +export const GET: RequestHandler = async ({ locals, url }) => { + const bucket = url.searchParams.get('bucket')?.trim(); + if (!bucket) throw error(400, 'Missing required query parameter: bucket'); + + const key = url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); + + const internalPath = url.searchParams.get('path')?.trim(); + if (!internalPath) throw error(400, 'Missing required query parameter: path'); + + const nestedArchivePath = url.searchParams.get('nestedArchivePath')?.trim() || undefined; + + const format = getArchiveFormat(key); + if (!format) { + throw error(400, `Unsupported archive format: ${key}`); + } + + const config = locals.storageConfig; + const connectionId = locals.storageConnectionId; + if (!config || !connectionId) { + throw error(401, 'No storage connection configured'); + } + + locals.logger.debug( + { bucket, key, internal_path: internalPath, nested_archive_path: nestedArchivePath, format }, + 'extracting archive entry' + ); + + const provider = withStorageHttpErrors(getProvider(config, bucket)); + const downloadFn = (k: string) => provider.getObject(k).then((d) => d.stream); + const metadataFn = (k: string) => provider.getMetadata(k); + + const data = await extractArchiveEntry( + bucket, + key, + internalPath, + downloadFn, + metadataFn, + nestedArchivePath, + archivePreviewMaxBytes, + connectionId + ); + + if (!data) { + throw error(404, `File "${internalPath}" not found in archive`); + } + + const contentType = guessContentType(internalPath); + + return new Response(data as BodyInit, { + headers: { + 'Content-Type': contentType, + 'Content-Length': String(data.length), + 'Cache-Control': 'private, no-store' + } + }); +}; + +function guessContentType(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + const mime = new Map([ + ['txt', 'text/plain'], + ['csv', 'text/csv'], + ['json', 'application/json'], + ['xml', 'application/xml'], + ['html', 'text/html'], + ['css', 'text/css'], + ['js', 'application/javascript'], + ['md', 'text/markdown'], + ['yaml', 'application/x-yaml'], + ['yml', 'application/x-yaml'], + ['parquet', 'application/octet-stream'], + ['pdf', 'application/pdf'], + ['png', 'image/png'], + ['jpg', 'image/jpeg'], + ['jpeg', 'image/jpeg'], + ['gif', 'image/gif'], + ['svg', 'image/svg+xml'], + ['webp', 'image/webp'], + ['log', 'text/plain'], + ['py', 'text/plain'], + ['java', 'text/plain'], + ['ts', 'text/plain'], + ['sql', 'text/plain'] + ]); + return mime.get(ext) ?? 'application/octet-stream'; +} diff --git a/src/routes/(app)/api/storage/archive/listing/+server.ts b/src/routes/(app)/api/storage/archive/listing/+server.ts new file mode 100644 index 00000000..15ab10f5 --- /dev/null +++ b/src/routes/(app)/api/storage/archive/listing/+server.ts @@ -0,0 +1,58 @@ +import { error } from '@sveltejs/kit'; +import { listArchiveContents } from '$lib/server/storage/archive.js'; +import { getProvider } from '$lib/server/storage/utils.js'; +import { withStorageHttpErrors } from '$lib/server/storage/wrap-provider.js'; +import { archivePreviewMaxBytes } from '$lib/server/feature-flags.js'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/storage/archive/listing?bucket=&key=&internalPrefix=&nestedArchivePath= + * + * Lists the contents of an archive file at the given internal path. + * Supports nested archives via the optional `nestedArchivePath` parameter: + * when set, the server extracts the nested archive from the outer one first. + * + * The archive is downloaded from S3 and cached server-side for 30 minutes + * so that subsequent navigations within the same archive do not incur + * additional S3 transfer costs. + * + * The connection config is parsed and validated by the `handleStorageConnection` + * middleware in hooks.server.ts before this handler runs. + */ +export const GET: RequestHandler = async ({ locals, url }) => { + const bucket = url.searchParams.get('bucket')?.trim(); + if (!bucket) throw error(400, 'Missing required query parameter: bucket'); + + const key = url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); + + const internalPrefix = url.searchParams.get('internalPrefix') ?? ''; + const nestedArchivePath = url.searchParams.get('nestedArchivePath')?.trim() || undefined; + + const config = locals.storageConfig; + const connectionId = locals.storageConnectionId; + if (!config || !connectionId) { + throw error(401, 'No storage connection configured'); + } + + locals.logger.debug( + { bucket, key, internal_prefix: internalPrefix, nested_archive_path: nestedArchivePath }, + 'listing archive contents' + ); + + const provider = withStorageHttpErrors(getProvider(config, bucket)); + const downloadFn = (k: string) => provider.getObject(k).then((d) => d.stream); + const metadataFn = (k: string) => provider.getMetadata(k); + + const listing = await listArchiveContents( + bucket, + key, + internalPrefix, + downloadFn, + metadataFn, + nestedArchivePath, + archivePreviewMaxBytes, + connectionId + ); + return Response.json(listing); +}; diff --git a/src/routes/(app)/api/storage/buckets/+server.ts b/src/routes/(app)/api/storage/buckets/+server.ts index 54994560..0767b96e 100644 --- a/src/routes/(app)/api/storage/buckets/+server.ts +++ b/src/routes/(app)/api/storage/buckets/+server.ts @@ -1,16 +1,44 @@ -import { listBuckets } from '$lib/server/storage/service.js'; +import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +import type { BucketDetails } from '$lib/storage/details-types.js'; +import { getConnectionProvider } from '$lib/server/storage/utils.js'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import { withStorageHttpErrors } from '$lib/server/storage/wrap-provider.js'; -/** - * GET /storage/api/buckets - * - * Returns the list of buckets accessible with the connection config supplied in - * the `X-Storage-Connection` request header (base64-encoded JSON). - * The config is parsed and validated by the `handleStorageConnection` middleware - * in hooks.server.ts before this handler runs. - */ -export const GET: RequestHandler = async ({ locals }) => { - const buckets = await listBuckets(locals.storageConfig!); - locals.logger.debug({ bucket_count: buckets.length }, 'bucket list returned'); - return Response.json(buckets); +export const GET: RequestHandler = async (event) => { + const detailsParam = event.url.searchParams.get('details'); + + if (detailsParam === 'true') { + const { provider, bucket } = createStorageProvider(event); + + event.locals.logger.debug({ bucket }, 'fetching bucket details'); + + const [versioning, lifecycleRules, tags, acl] = await Promise.all([ + provider.getBucketVersioning(), + provider.getBucketLifecycleRules(), + provider.getBucketTags(), + provider.getBucketAcl() + ]); + + const details: BucketDetails = { + name: bucket, + versioning: versioning as 'Enabled' | 'Suspended' | 'Disabled', + lifecycleRules, + tags, + acl + }; + + return json(details); + } + + const config = event.locals.storageConfig; + if (!config) { + throw error(401, 'No storage connection configured'); + } + + const listedBuckets = await withStorageHttpErrors(getConnectionProvider(config)).listContainers(); + const additional = config.additionalBuckets ?? []; + const allBuckets = [...new Set([...listedBuckets, ...additional])]; + event.locals.logger.debug({ bucket_count: allBuckets.length }, 'bucket list returned'); + return json(allBuckets); }; diff --git a/src/routes/(app)/api/storage/buckets/server.test.ts b/src/routes/(app)/api/storage/buckets/server.test.ts new file mode 100644 index 00000000..a1ecdcc7 --- /dev/null +++ b/src/routes/(app)/api/storage/buckets/server.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGetBucketVersioning = vi.fn(); +const mockGetBucketLifecycleRules = vi.fn(); +const mockGetBucketTags = vi.fn(); +const mockGetBucketAcl = vi.fn(); +const mockListContainers = vi.fn(); +const mockProvider = { + getBucketVersioning: mockGetBucketVersioning, + getBucketLifecycleRules: mockGetBucketLifecycleRules, + getBucketTags: mockGetBucketTags, + getBucketAcl: mockGetBucketAcl +}; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider, + getConnectionProvider: () => ({ listContainers: mockListContainers }) +})); + +import { GET } from './+server.js'; + +function mockEvent(searchParams: Record) { + const url = new URL('http://localhost/api/storage/buckets'); + for (const [k, v] of Object.entries(searchParams)) { + url.searchParams.set(k, v); + } + return { + url, + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + user: { id: 'test-user' }, + storageConfig: { + type: 's3', + host: 'localhost', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' } + } + } + } as unknown as Parameters[0]; +} + +describe('GET /api/storage/buckets', () => { + beforeEach(() => vi.clearAllMocks()); + + describe('bucket listing (no details param)', () => { + it('returns buckets from provider plus additional buckets', async () => { + mockListContainers.mockResolvedValue(['alpha', 'beta']); + + const event = mockEvent({}); + event.locals.storageConfig = { + type: 's3', + host: 'localhost', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' }, + additionalBuckets: ['gamma'] + }; + const response = await GET(event); + const body = await response.json(); + expect(body).toEqual(['alpha', 'beta', 'gamma']); + }); + + it('deduplicates overlapping provider and additional buckets', async () => { + mockListContainers.mockResolvedValue(['alpha', 'beta']); + + const event = mockEvent({}); + event.locals.storageConfig = { + type: 's3', + host: 'localhost', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' }, + additionalBuckets: ['beta'] + }; + const response = await GET(event); + const body = await response.json(); + expect(body).toEqual(['alpha', 'beta']); + }); + + it('lists buckets when prefix is set without details', async () => { + mockListContainers.mockResolvedValue(['alpha', 'beta']); + + const response = await GET(mockEvent({ prefix: 'some/prefix/' })); + const body = await response.json(); + expect(body).toEqual(['alpha', 'beta']); + }); + }); + + describe('bucket details (details=true)', () => { + it('throws 400 when bucket is missing', async () => { + await expect(GET(mockEvent({ details: 'true' }))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('returns bucket details with Enabled versioning', async () => { + mockGetBucketVersioning.mockResolvedValue('Enabled'); + mockGetBucketLifecycleRules.mockResolvedValue([]); + mockGetBucketTags.mockResolvedValue({ env: 'prod' }); + mockGetBucketAcl.mockResolvedValue({ owner: 'admin', grants: [] }); + + const response = await GET(mockEvent({ details: 'true', bucket: 'b1' })); + const body = await response.json(); + expect(body).toMatchObject({ + name: 'b1', + versioning: 'Enabled', + tags: { env: 'prod' }, + acl: { owner: 'admin', grants: [] }, + lifecycleRules: [] + }); + }); + + it('preserves Suspended versioning state', async () => { + mockGetBucketVersioning.mockResolvedValue('Suspended'); + mockGetBucketLifecycleRules.mockResolvedValue([]); + mockGetBucketTags.mockResolvedValue({}); + mockGetBucketAcl.mockResolvedValue({ owner: 'admin', grants: [] }); + + const response = await GET(mockEvent({ details: 'true', bucket: 'b1' })); + const body = await response.json(); + expect(body.versioning).toBe('Suspended'); + }); + + it('returns Disabled versioning as default', async () => { + mockGetBucketVersioning.mockResolvedValue('Disabled'); + mockGetBucketLifecycleRules.mockResolvedValue([]); + mockGetBucketTags.mockResolvedValue({}); + mockGetBucketAcl.mockResolvedValue({ owner: 'admin', grants: [] }); + + const response = await GET(mockEvent({ details: 'true', bucket: 'b1' })); + const body = await response.json(); + expect(body.versioning).toBe('Disabled'); + }); + + it('includes lifecycle rules and ACL grants', async () => { + const rules = [ + { + id: 'rule-1', + status: 'Enabled', + filter: { prefix: 'logs/' }, + transitions: [], + expirations: [{ days: 30 }], + noncurrentVersionTransitions: [], + noncurrentVersionExpirations: [], + abortIncompleteMultipartUploads: [] + } + ]; + const acl = { + owner: 'owner', + grants: [{ grantee: 'user1', permission: 'FULL_CONTROL' }] + }; + mockGetBucketVersioning.mockResolvedValue('Enabled'); + mockGetBucketLifecycleRules.mockResolvedValue(rules); + mockGetBucketTags.mockResolvedValue({}); + mockGetBucketAcl.mockResolvedValue(acl); + + const response = await GET(mockEvent({ details: 'true', bucket: 'b1' })); + const body = await response.json(); + expect(body.lifecycleRules).toEqual(rules); + expect(body.acl).toEqual(acl); + }); + }); +}); diff --git a/src/routes/(app)/api/storage/check-bucket/+server.ts b/src/routes/(app)/api/storage/check-bucket/+server.ts new file mode 100644 index 00000000..6c2ed987 --- /dev/null +++ b/src/routes/(app)/api/storage/check-bucket/+server.ts @@ -0,0 +1,23 @@ +import { error, isHttpError } from '@sveltejs/kit'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/storage/check-bucket?bucket= + * + * Verifies that the configured bucket is reachable and accessible. + * Returns 204 on success, 502 if the bucket cannot be reached. + */ +export const GET: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + + try { + await provider.listObjects('', 1); + event.locals.logger.debug({ bucket }, 'bucket access check passed'); + return new Response(null, { status: 204 }); + } catch (err) { + if (isHttpError(err)) throw err; + event.locals.logger.warn({ err, bucket }, 'unexpected error during bucket access check'); + throw error(502, 'Could not reach bucket'); + } +}; diff --git a/src/routes/(app)/api/storage/check-bucket/server.test.ts b/src/routes/(app)/api/storage/check-bucket/server.test.ts new file mode 100644 index 00000000..815ce61c --- /dev/null +++ b/src/routes/(app)/api/storage/check-bucket/server.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockListObjects = vi.fn(); + +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: vi.fn(() => ({ listObjects: mockListObjects })) +})); + +import { GET } from './+server.js'; +import { error } from '@sveltejs/kit'; + +function mockEvent(params: string) { + const url = new URL(`http://localhost/api/storage/check-bucket?${params}`); + return { + url, + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + storageConfig: { + type: 's3', + host: 'localhost', + accessStyle: 'Path' as const, + region: { name: 'us-east-1' } + } + } + } as unknown as Parameters[0]; +} + +describe('GET /api/storage/check-bucket', () => { + beforeEach(() => vi.clearAllMocks()); + + it('throws 400 when bucket is missing', async () => { + await expect(GET(mockEvent(''))).rejects.toThrow(expect.objectContaining({ status: 400 })); + }); + + it('returns 204 when bucket is accessible', async () => { + mockListObjects.mockResolvedValue({ items: [], continuationToken: undefined }); + + const res = await GET(mockEvent('bucket=my-bucket')); + + expect(mockListObjects).toHaveBeenCalledWith('', 1); + expect(res.status).toBe(204); + }); + + it('re-throws 404 HttpError when bucket is not found', async () => { + mockListObjects.mockImplementation(() => { + error(404, 'Bucket not found'); + }); + + await expect(GET(mockEvent('bucket=missing-bucket'))).rejects.toMatchObject({ status: 404 }); + }); + + it('re-throws 403 HttpError when access is denied', async () => { + mockListObjects.mockImplementation(() => { + error(403, 'Access denied'); + }); + + await expect(GET(mockEvent('bucket=secret-bucket'))).rejects.toMatchObject({ status: 403 }); + }); + + it('throws 502 for unexpected errors', async () => { + mockListObjects.mockRejectedValue(new Error('network failure')); + + await expect(GET(mockEvent('bucket=my-bucket'))).rejects.toThrow( + expect.objectContaining({ status: 502 }) + ); + }); +}); diff --git a/src/routes/(app)/api/storage/connections/+server.ts b/src/routes/(app)/api/storage/connections/+server.ts new file mode 100644 index 00000000..a71beb93 --- /dev/null +++ b/src/routes/(app)/api/storage/connections/+server.ts @@ -0,0 +1,109 @@ +import { desc, eq, and } from 'drizzle-orm'; +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db.js'; +import { userStorageConnections } from '$lib/server/schema.js'; +import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/storage/connections + * + * Returns the authenticated user's saved storage connections, sorted by + * most recently used first. Credentials are never included in the response. + */ +export const GET: RequestHandler = async ({ locals }) => { + const userId = locals.user!.id; + + const rows = await db + .select({ + id: userStorageConnections.id, + name: userStorageConnections.name, + encryptedPayload: userStorageConnections.encryptedPayload, + additionalBuckets: userStorageConnections.additionalBuckets, + createdAt: userStorageConnections.createdAt, + updatedAt: userStorageConnections.updatedAt + }) + .from(userStorageConnections) + .where(eq(userStorageConnections.userId, userId)) + .orderBy(desc(userStorageConnections.updatedAt)); + + // Decrypt the endpoint for display purposes — we include it in the response + // so the UI can show a label, but never include the access keys or secret. + const { decrypt } = await import('$lib/server/storage/encryption.js'); + const { storageEncryptionKey: getKey } = await import('$lib/server/storage/encryption-key.js'); + + const connections = rows.map((row) => { + let endpoint: string | null = null; + try { + const payload = JSON.parse(decrypt(row.encryptedPayload, getKey())) as { + host?: string; + port?: number; + }; + endpoint = + payload.host && payload.port ? `${payload.host}:${payload.port}` : (payload.host ?? null); + } catch { + // If decryption fails for a row, we still return the entry without the endpoint. + } + return { + id: row.id, + name: row.name, + endpoint, + additionalBuckets: (row.additionalBuckets as string[]) ?? [], + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString() + }; + }); + + locals.logger.debug({ connection_count: connections.length }, 'connections list returned'); + return Response.json(connections); +}; + +/** + * PATCH /api/storage/connections + * + * Adds a bucket to the active connection's `additionalBuckets` list. + * Requires the `x-storage-connection-id` header. The bucket is only added + * if the caller has already verified access (e.g. via check-bucket). + */ +export const PATCH: RequestHandler = async ({ request, locals }) => { + const userId = locals.user!.id; + const connectionId = request.headers.get(STORAGE_CONNECTION_ID_HEADER); + if (!connectionId) { + return json({ error: 'Missing storage connection ID' }, { status: 400 }); + } + + const body = (await request.json()) as { bucket?: string }; + const bucket = body.bucket?.trim(); + if (!bucket) { + return json({ error: 'Bucket name is required' }, { status: 400 }); + } + + const rows = await db + .select({ + id: userStorageConnections.id, + additionalBuckets: userStorageConnections.additionalBuckets + }) + .from(userStorageConnections) + .where( + and(eq(userStorageConnections.id, connectionId), eq(userStorageConnections.userId, userId)) + ) + .limit(1); + + if (rows.length === 0) { + return json({ error: 'Connection not found' }, { status: 404 }); + } + + const existing = (rows[0].additionalBuckets as string[]) ?? []; + if (existing.includes(bucket)) { + return json({ ok: true }); + } + + const updated = [...existing, bucket]; + await db + .update(userStorageConnections) + .set({ additionalBuckets: updated, updatedAt: new Date() }) + .where(eq(userStorageConnections.id, connectionId)); + + locals.logger.info({ connection_id: connectionId, bucket }, 'bucket added to connection'); + return json({ ok: true }); +}; diff --git a/src/routes/(app)/api/storage/copy/+server.ts b/src/routes/(app)/api/storage/copy/+server.ts new file mode 100644 index 00000000..d283a42c --- /dev/null +++ b/src/routes/(app)/api/storage/copy/+server.ts @@ -0,0 +1,43 @@ +import type { RequestHandler } from './$types'; +import { error } from '@sveltejs/kit'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import { performCopyOrMove } from '$lib/server/storage/copy-move.js'; + +export const POST: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const streamProgress = event.url.searchParams.get('progress') === 'true'; + const { locals, request } = event; + + const body = (await request.json()) as { + sourceKeys: string[]; + destinationPrefix: string; + jobId?: string; + }; + if (!body.sourceKeys?.length) { + throw error(400, 'Missing required body field: sourceKeys'); + } + if (body.destinationPrefix === undefined) { + throw error(400, 'Missing required body field: destinationPrefix'); + } + + locals.logger.debug( + { + bucket, + source_key_count: body.sourceKeys.length, + destination_prefix: body.destinationPrefix, + stream_progress: streamProgress + }, + 'copy request received' + ); + + return performCopyOrMove({ + provider, + sourceKeys: body.sourceKeys, + destinationPrefix: body.destinationPrefix, + streamProgress, + logger: locals.logger, + bucket, + jobId: body.jobId, + deleteOriginals: false + }); +}; diff --git a/src/routes/(app)/api/storage/copy/job/[jobId]/+server.ts b/src/routes/(app)/api/storage/copy/job/[jobId]/+server.ts new file mode 100644 index 00000000..4f5c0923 --- /dev/null +++ b/src/routes/(app)/api/storage/copy/job/[jobId]/+server.ts @@ -0,0 +1,12 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { getJob } from '$lib/server/storage/job-store.js'; + +export const GET: RequestHandler = async ({ params }) => { + const { jobId } = params; + const job = getJob(jobId); + if (!job) { + return json({ status: 'not_found' }, { status: 404 }); + } + return json(job); +}; diff --git a/src/routes/(app)/api/storage/create/+server.ts b/src/routes/(app)/api/storage/create/+server.ts new file mode 100644 index 00000000..b35fc93b --- /dev/null +++ b/src/routes/(app)/api/storage/create/+server.ts @@ -0,0 +1,27 @@ +import { error } from '@sveltejs/kit'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import type { RequestHandler } from './$types'; + +/** + * POST /api/storage/create?bucket=&key= + * + * Creates an empty object (or directory marker when the key ends with `/`). + * The connection config is parsed and validated by the `handleStorageConnection` + * middleware in hooks.server.ts before this handler runs. + */ +export const POST: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const key = event.url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); + const log = event.locals.logger; + + const contentType = key.endsWith('/') ? 'application/x-directory' : 'text/plain'; + + log.debug({ bucket, key }, 'creating object'); + + await provider.putObject(key, Buffer.alloc(0), contentType, 0); + + log.info({ bucket, key, content_type: contentType }, 'object created'); + + return new Response(null, { status: 201 }); +}; diff --git a/src/routes/(app)/api/storage/create/server.test.ts b/src/routes/(app)/api/storage/create/server.test.ts new file mode 100644 index 00000000..4272a470 --- /dev/null +++ b/src/routes/(app)/api/storage/create/server.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockProvider = { putObject: vi.fn() }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider +})); + +import { POST } from './+server.js'; + +const CONNECTION_HEADER = { + 'x-storage-connection': btoa(JSON.stringify({ type: 's3', region: 'us-east-1' })) +}; + +function mockEvent(params: string) { + const url = new URL(`http://localhost/api/storage/create?${params}`); + return { + url, + request: { headers: new Headers(CONNECTION_HEADER) }, + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + user: { id: 'test-user' }, + storageConfig: { type: 's3', region: { name: 'us-east-1' } } + } + } as unknown as Parameters[0]; +} + +describe('POST /api/storage/create', () => { + beforeEach(() => vi.clearAllMocks()); + + it('creates a directory marker when key ends with /', async () => { + mockProvider.putObject.mockResolvedValue(undefined); + + const res = await POST(mockEvent('bucket=b1&key=folder/subfolder/')); + + expect(res.status).toBe(201); + expect(mockProvider.putObject).toHaveBeenCalledWith( + 'folder/subfolder/', + expect.any(Buffer), + 'application/x-directory', + 0 + ); + }); + + it('creates a plain text object for non-directory keys', async () => { + mockProvider.putObject.mockResolvedValue(undefined); + + const res = await POST(mockEvent('bucket=b1&key=my-file.txt')); + + expect(res.status).toBe(201); + expect(mockProvider.putObject).toHaveBeenCalledWith( + 'my-file.txt', + expect.any(Buffer), + 'text/plain', + 0 + ); + }); + + it('returns 400 when key is missing', async () => { + await expect(POST(mockEvent('bucket=b1'))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('propagates provider errors as 502', async () => { + mockProvider.putObject.mockRejectedValue(new Error('S3 connection refused')); + + await expect(POST(mockEvent('bucket=b1&key=fail.txt'))).rejects.toThrow( + expect.objectContaining({ status: 502 }) + ); + }); +}); diff --git a/src/routes/(app)/api/storage/delete/+server.ts b/src/routes/(app)/api/storage/delete/+server.ts index 43369e83..254bb979 100644 --- a/src/routes/(app)/api/storage/delete/+server.ts +++ b/src/routes/(app)/api/storage/delete/+server.ts @@ -1,30 +1,25 @@ -import type { RequestHandler } from './$types'; import { error } from '@sveltejs/kit'; -import { deleteObjects } from '$lib/server/storage/service.js'; -import { requireBucket } from '../params.js'; +import type { RequestHandler } from './$types'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; /** - * DELETE /storage/api/delete?bucket=&keys=&keys=&... - * - * Deletes one or more S3 objects from the given bucket. - * Authentication is enforced by the app-level auth guard in hooks.server.ts. + * DELETE /api/storage/delete?bucket= * - * The connection config is parsed and validated by the `handleStorageConnection` - * middleware in hooks.server.ts before this handler runs. + * Deletes one or more objects from the storage bucket. The request body must + * be JSON: `{ keys: string[] }`. Returns the deletion result including any failures. */ -export const DELETE: RequestHandler = async ({ locals, url }) => { - const bucket = requireBucket(url); +export const DELETE: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const { keys } = (await event.request.json()) as { keys?: string[] }; + const log = event.locals.logger; - const keys = url.searchParams.getAll('keys'); - if (!keys.length) { - throw error(400, 'Missing required query parameter: keys'); - } + if (!keys?.length) throw error(400, 'Missing required body field: keys'); - locals.logger.debug({ bucket, key_count: keys.length }, 'delete request received'); + log.debug({ bucket, key_count: keys.length }, 'delete request received'); - const result = await deleteObjects(locals.storageConfig!, bucket, keys); + const result = await provider.deleteObjects(keys); - locals.logger.info( + log.info( { bucket, key_count: keys.length, failed_count: result.failed.length }, 'objects delete completed' ); diff --git a/src/routes/(app)/api/storage/delete/server.test.ts b/src/routes/(app)/api/storage/delete/server.test.ts index 7d2dcd9e..d7a5d47d 100644 --- a/src/routes/(app)/api/storage/delete/server.test.ts +++ b/src/routes/(app)/api/storage/delete/server.test.ts @@ -1,19 +1,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { faker } from '@faker-js/faker'; -vi.mock('$lib/server/storage/service.js', () => ({ - deleteObjects: vi.fn() +const mockProvider = { deleteObjects: vi.fn() }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider })); import { DELETE } from './+server.js'; -import { deleteObjects } from '$lib/server/storage/service.js'; const CONNECTION_HEADER = { 'x-storage-connection': btoa(JSON.stringify({ type: 's3', region: 'us-east-1' })) }; -function mockEvent(searchParams: Record) { - const url = new URL('http://localhost/storage/api/delete'); +function mockEvent(searchParams: Record, keys?: string[]) { + const url = new URL('http://localhost/api/storage/delete'); for (const [k, v] of Object.entries(searchParams)) { if (Array.isArray(v)) { v.forEach((val) => url.searchParams.append(k, val)); @@ -23,20 +23,24 @@ function mockEvent(searchParams: Record) { } return { url, - request: { headers: new Headers(CONNECTION_HEADER) }, + request: new Request(url, { + method: 'DELETE', + headers: { ...CONNECTION_HEADER, 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys }) + }), locals: { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, user: { id: 'test-user' }, - storageConfig: { type: 's3', region: 'us-east-1' } + storageConfig: { type: 's3', region: { name: 'us-east-1' } } } } as unknown as Parameters[0]; } -describe('DELETE /storage/api/delete', () => { +describe('DELETE /api/storage/delete', () => { beforeEach(() => vi.clearAllMocks()); it('throws 400 when bucket is missing', async () => { - await expect(DELETE(mockEvent({ keys: 'a.txt' }))).rejects.toThrow( + await expect(DELETE(mockEvent({}, ['a.txt']))).rejects.toThrow( expect.objectContaining({ status: 400 }) ); }); @@ -50,12 +54,10 @@ describe('DELETE /storage/api/delete', () => { it('deletes objects and returns JSON result', async () => { const keys = [faker.system.fileName(), faker.system.fileName()]; const result = { deleted: keys, failed: [] }; - vi.mocked(deleteObjects).mockResolvedValue( - result as unknown as Awaited> - ); + mockProvider.deleteObjects.mockResolvedValue(result); - const response = await DELETE(mockEvent({ bucket: 'b1', keys })); - expect(deleteObjects).toHaveBeenCalledWith(expect.objectContaining({ type: 's3' }), 'b1', keys); + const response = await DELETE(mockEvent({ bucket: 'b1' }, keys)); + expect(mockProvider.deleteObjects).toHaveBeenCalledWith(keys); expect(await response.json()).toEqual(result); }); }); diff --git a/src/routes/(app)/api/storage/details/+server.ts b/src/routes/(app)/api/storage/details/+server.ts new file mode 100644 index 00000000..cc118667 --- /dev/null +++ b/src/routes/(app)/api/storage/details/+server.ts @@ -0,0 +1,34 @@ +import { error } from '@sveltejs/kit'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import type { FileDetails } from '$lib/storage/details-types.js'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/storage/details?bucket=&key= + * + * Returns the full metadata for a single storage object. + */ +export const GET: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const key = event.url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); + + event.locals.logger.debug({ bucket, key }, 'fetching object details'); + + const meta = await provider.getMetadata(key); + + const details: FileDetails = { + key, + name: key.split('/').filter(Boolean).pop() ?? key, + size: meta.size, + lastModified: meta.lastModified, + contentType: meta.contentType, + etag: meta.etag, + customMetadata: meta.customMetadata, + versionId: meta.versionId, + storageClass: meta.storageClass, + isDeleteMarker: meta.isDeleteMarker ?? false + }; + + return Response.json(details); +}; diff --git a/src/routes/(app)/api/storage/details/server.test.ts b/src/routes/(app)/api/storage/details/server.test.ts new file mode 100644 index 00000000..33a00485 --- /dev/null +++ b/src/routes/(app)/api/storage/details/server.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGetMetadata = vi.fn(); +const mockProvider = { getMetadata: mockGetMetadata }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider +})); + +import { GET } from './+server.js'; + +const CONNECTION_HEADER = { + 'x-storage-connection': btoa(JSON.stringify({ type: 's3', region: 'us-east-1' })) +}; + +function mockEvent(searchParams: Record) { + const url = new URL('http://localhost/api/storage/details'); + for (const [k, v] of Object.entries(searchParams)) { + url.searchParams.set(k, v); + } + return { + url, + request: { headers: new Headers(CONNECTION_HEADER) }, + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + user: { id: 'test-user' }, + storageConfig: { type: 's3', region: { name: 'us-east-1' } } + } + } as unknown as Parameters[0]; +} + +describe('GET /api/storage/details', () => { + beforeEach(() => vi.clearAllMocks()); + + it('throws 400 when bucket is missing', async () => { + await expect(GET(mockEvent({ key: 'file.txt' }))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('throws 400 when key is missing', async () => { + await expect(GET(mockEvent({ bucket: 'b1' }))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('returns file details', async () => { + const meta = { + size: 1024, + lastModified: new Date('2025-01-01'), + contentType: 'text/plain', + etag: '"abc123"', + customMetadata: { author: 'test' }, + versionId: 'v1', + storageClass: 'STANDARD', + isDeleteMarker: false + }; + mockGetMetadata.mockResolvedValue(meta); + + const response = await GET(mockEvent({ bucket: 'b1', key: 'path/file.txt' })); + expect(mockGetMetadata).toHaveBeenCalledWith('path/file.txt'); + const body = await response.json(); + expect(body).toMatchObject({ + key: 'path/file.txt', + name: 'file.txt', + size: 1024, + contentType: 'text/plain', + etag: '"abc123"', + customMetadata: { author: 'test' }, + storageClass: 'STANDARD', + isDeleteMarker: false + }); + }); + + it('handles missing optional fields', async () => { + mockGetMetadata.mockResolvedValue({ + size: 0, + lastModified: new Date(0), + contentType: undefined, + etag: undefined, + customMetadata: undefined, + versionId: undefined, + storageClass: undefined, + isDeleteMarker: false + }); + + const response = await GET(mockEvent({ bucket: 'b1', key: 'test.dat' })); + const body = await response.json(); + expect(body.contentType).toBeUndefined(); + expect(body.etag).toBeUndefined(); + expect(body.storageClass).toBeUndefined(); + }); +}); diff --git a/src/routes/(app)/api/storage/directory-metadata/+server.ts b/src/routes/(app)/api/storage/directory-metadata/+server.ts new file mode 100644 index 00000000..53679984 --- /dev/null +++ b/src/routes/(app)/api/storage/directory-metadata/+server.ts @@ -0,0 +1,42 @@ +import { error } from '@sveltejs/kit'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import type { DirectoryMetadata } from '$lib/storage/details-types.js'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/storage/directory-metadata?bucket=&prefix= + * + * Returns metadata for a directory, including the bucket ACL and, if present, + * the directory marker object's metadata. + */ +export const GET: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const prefix = event.url.searchParams.get('prefix')?.trim(); + if (!prefix) throw error(400, 'Missing required query parameter: prefix'); + + event.locals.logger.debug({ bucket, prefix }, 'fetching directory metadata'); + + const acl = await provider.getBucketAcl(); + const result: DirectoryMetadata = { + bucketOwner: acl.owner, + bucketGrants: acl.grants, + markerExists: false + }; + + try { + const meta = await provider.getMetadata(prefix); + result.markerExists = true; + result.markerLastModified = meta.lastModified.toISOString(); + result.markerContentType = meta.contentType; + result.markerETag = meta.etag; + result.markerContentLength = meta.size; + result.markerVersionId = meta.versionId; + result.markerStorageClass = meta.storageClass; + result.markerIsDeleteMarker = meta.isDeleteMarker; + result.markerCustomMetadata = meta.customMetadata; + } catch { + // No directory marker object — that's fine + } + + return Response.json(result); +}; diff --git a/src/routes/(app)/api/storage/directory-metadata/server.test.ts b/src/routes/(app)/api/storage/directory-metadata/server.test.ts new file mode 100644 index 00000000..c01369ed --- /dev/null +++ b/src/routes/(app)/api/storage/directory-metadata/server.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGetBucketAcl = vi.fn(); +const mockGetMetadata = vi.fn(); +const mockProvider = { + getBucketAcl: mockGetBucketAcl, + getMetadata: mockGetMetadata +}; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider +})); + +import { GET } from './+server.js'; + +const CONNECTION_HEADER = { + 'x-storage-connection': btoa(JSON.stringify({ type: 's3', region: 'us-east-1' })) +}; + +function mockEvent(searchParams: Record) { + const url = new URL('http://localhost/api/storage/directory-metadata'); + for (const [k, v] of Object.entries(searchParams)) { + url.searchParams.set(k, v); + } + return { + url, + request: { headers: new Headers(CONNECTION_HEADER) }, + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + user: { id: 'test-user' }, + storageConfig: { type: 's3', region: { name: 'us-east-1' } } + } + } as unknown as Parameters[0]; +} + +describe('GET /api/storage/directory-metadata', () => { + beforeEach(() => vi.clearAllMocks()); + + it('throws 400 when bucket is missing', async () => { + await expect(GET(mockEvent({ prefix: 'dir/' }))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('throws 400 when prefix is missing', async () => { + await expect(GET(mockEvent({ bucket: 'b1' }))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('returns directory metadata without marker', async () => { + mockGetBucketAcl.mockResolvedValue({ owner: 'admin', grants: [] }); + mockGetMetadata.mockRejectedValue(new Error('NoSuchKey')); + + const response = await GET(mockEvent({ bucket: 'b1', prefix: 'dir/' })); + const body = await response.json(); + expect(body).toMatchObject({ + bucketOwner: 'admin', + bucketGrants: [], + markerExists: false + }); + expect(mockGetMetadata).toHaveBeenCalled(); + }); + + it('returns directory metadata with marker when object exists', async () => { + mockGetBucketAcl.mockResolvedValue({ owner: 'admin', grants: [] }); + mockGetMetadata.mockResolvedValue({ + size: 0, + lastModified: new Date('2025-06-01'), + contentType: 'application/octet-stream', + etag: '"def456"', + versionId: 'v2', + storageClass: 'STANDARD', + isDeleteMarker: false + }); + + const response = await GET(mockEvent({ bucket: 'b1', prefix: 'dir/' })); + const body = await response.json(); + expect(body.markerExists).toBe(true); + expect(body.markerLastModified).toBeDefined(); + expect(body.markerETag).toBe('"def456"'); + expect(body.markerVersionId).toBe('v2'); + }); + + it('handles marker metadata fetch failure gracefully', async () => { + mockGetBucketAcl.mockResolvedValue({ owner: 'admin', grants: [] }); + mockGetMetadata.mockRejectedValue(new Error('not found')); + + const response = await GET(mockEvent({ bucket: 'b1', prefix: 'dir/' })); + const body = await response.json(); + expect(body.markerExists).toBe(false); + }); +}); diff --git a/src/routes/(app)/api/storage/directory-size/+server.ts b/src/routes/(app)/api/storage/directory-size/+server.ts new file mode 100644 index 00000000..f362e98b --- /dev/null +++ b/src/routes/(app)/api/storage/directory-size/+server.ts @@ -0,0 +1,85 @@ +import { error } from '@sveltejs/kit'; +import type { DirectorySizeEvent } from '$lib/storage/details-types.js'; +import { buildTree, buildChildrenByDepth } from '$lib/server/storage/directory-tree.js'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const prefix = event.url.searchParams.get('prefix')?.trim(); + + if (!prefix) throw error(400, 'Missing required query parameter: prefix'); + + event.locals.logger.debug({ bucket, prefix }, 'calculating directory size'); + + const allKeys: Array<{ key: string; size: number; lastModified?: Date }> = []; + const startTime = Date.now(); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + try { + await provider.listAllKeysProgressively(prefix, (batch) => { + allKeys.push(...batch); + + const progress: DirectorySizeEvent = { + type: 'progress', + keysFound: allKeys.length, + totalSize: allKeys.reduce((sum, k) => sum + k.size, 0) + }; + + controller.enqueue(encoder.encode(`data: ${JSON.stringify(progress)}\n\n`)); + }); + + const tree = buildTree(prefix, allKeys); + const actualMaxDepth = + allKeys.length > 0 + ? Math.max( + 1, + ...allKeys.map(({ key }) => { + const relative = key.slice(prefix.length); + return relative.split('/').filter(Boolean).length; + }) + ) + : 1; + const childrenByDepth = buildChildrenByDepth(prefix, allKeys, actualMaxDepth); + let totalFiles = 0; + let totalDirectories = 0; + for (const { key } of allKeys) { + if (key.endsWith('/')) totalDirectories++; + else totalFiles++; + } + + const result: DirectorySizeEvent = { + type: 'complete', + totalSize: allKeys.reduce((sum, k) => sum + k.size, 0), + totalKeys: allKeys.length, + totalFiles, + totalDirectories, + tree, + childrenByDepth, + maxDepth: actualMaxDepth, + durationMs: Date.now() - startTime + }; + + controller.enqueue(encoder.encode(`data: ${JSON.stringify(result)}\n\n`)); + controller.close(); + } catch (err) { + const errorEvent: DirectorySizeEvent = { + type: 'error', + message: err instanceof Error ? err.message : 'Unknown error' + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)); + controller.close(); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + }); +}; diff --git a/src/routes/(app)/api/storage/download/+server.ts b/src/routes/(app)/api/storage/download/+server.ts index d8e09bb8..7fc6f0f0 100644 --- a/src/routes/(app)/api/storage/download/+server.ts +++ b/src/routes/(app)/api/storage/download/+server.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from './$types'; -import { downloadObject, getObjectMetadata } from '$lib/server/storage/service.js'; -import { requireBucketKey } from '../params.js'; +import { error } from '@sveltejs/kit'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; /** Derive the bare filename from a (possibly path-prefixed) object key. */ function filenameFromKey(key: string): string { @@ -8,22 +8,43 @@ function filenameFromKey(key: string): string { } /** - * GET /storage/api/download?bucket=&key= + * GET /api/storage/download?bucket=&key= * * Proxies an S3 object directly to the client as a streaming download. * Authentication is enforced by the app-level auth guard in hooks.server.ts. * The S3 body stream is piped straight to the HTTP response — no server-side * buffering occurs. * - * The connection config is parsed and validated by the `handleStorageConnection` - * middleware in hooks.server.ts before this handler runs. + * The connection config is resolved from `locals.storageConfig` which is set + * by the handleStorageConnection middleware using the x-storage-connection-id + * header and a database lookup. */ -export const GET: RequestHandler = async ({ locals, url }) => { - const { bucket, key } = requireBucketKey(url); +export const GET: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const key = event.url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); + const { locals, request } = event; locals.logger.debug({ bucket, key }, 'download request received'); - const download = await downloadObject(locals.storageConfig!, bucket, key); + const download = await provider.getObject(key); + + // When the client cancels the download (closes the connection), abort the S3 + // stream proactively so the backend stops fetching data from S3. + const abortController = new AbortController(); + request.signal.addEventListener( + 'abort', + () => { + locals.logger.info({ bucket, key }, 'client cancelled download — aborting S3 stream'); + abortController.abort(); + }, + { once: true } + ); + + // Pipe the S3 stream through a TransformStream that honours the abort signal. + // This ensures the S3 SDK stops reading when the client disconnects. + const { readable, writable } = new TransformStream(); + download.stream.pipeTo(writable, { signal: abortController.signal }).catch(() => {}); const filename = filenameFromKey(key); // RFC 5987 encoding for non-ASCII filenames in Content-Disposition @@ -46,11 +67,11 @@ export const GET: RequestHandler = async ({ locals, url }) => { locals.logger.info({ bucket, key, filename }, 'streaming object download'); - return new Response(download.stream, { status: 200, headers }); + return new Response(readable, { status: 200, headers }); }; /** - * HEAD /storage/api/download?bucket=&key= + * HEAD /api/storage/download?bucket=&key= * * Lightweight pre-flight that validates credentials and access rights using * a HeadObject call (no object body transferred). The client uses this before @@ -59,12 +80,14 @@ export const GET: RequestHandler = async ({ locals, url }) => { * The connection config is parsed and validated by the `handleStorageConnection` * middleware in hooks.server.ts before this handler runs. */ -export const HEAD: RequestHandler = async ({ locals, url }) => { - const { bucket, key } = requireBucketKey(url); +export const HEAD: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const key = event.url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); - locals.logger.debug({ bucket, key }, 'download pre-flight check'); + event.locals.logger.debug({ bucket, key }, 'download pre-flight check'); - const meta = await getObjectMetadata(locals.storageConfig!, bucket, key); + const meta = await provider.getMetadata(key); return new Response(null, { status: 200, diff --git a/src/routes/(app)/api/storage/download/server.test.ts b/src/routes/(app)/api/storage/download/server.test.ts index 545b581b..d5b0bd74 100644 --- a/src/routes/(app)/api/storage/download/server.test.ts +++ b/src/routes/(app)/api/storage/download/server.test.ts @@ -1,41 +1,40 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('$lib/server/storage/service.js', () => ({ - downloadObject: vi.fn(), - getObjectMetadata: vi.fn() +const mockProvider = { getObject: vi.fn(), getMetadata: vi.fn() }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider })); import { GET, HEAD } from './+server.js'; -import { downloadObject, getObjectMetadata } from '$lib/server/storage/service.js'; const CONNECTION_HEADER = { 'x-storage-connection': btoa(JSON.stringify({ type: 's3', region: 'us-east-1' })) }; function mockEvent(params: string) { - const url = new URL(`http://localhost/storage/api/download?${params}`); + const url = new URL(`http://localhost/api/storage/download?${params}`); return { url, - request: { headers: new Headers(CONNECTION_HEADER) }, + request: { headers: new Headers(CONNECTION_HEADER), signal: new AbortController().signal }, locals: { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, user: { id: 'test-user' }, - storageConfig: { type: 's3', region: 'us-east-1' } + storageConfig: { type: 's3', region: { name: 'us-east-1' } } } } as unknown as Parameters[0]; } -describe('GET /storage/api/download', () => { +describe('GET /api/storage/download', () => { beforeEach(() => vi.clearAllMocks()); it('streams download with correct headers', async () => { const stream = new ReadableStream(); - vi.mocked(downloadObject).mockResolvedValue({ + mockProvider.getObject.mockResolvedValue({ stream, contentType: 'text/csv', contentLength: 1234, etag: '"abc"' - } as unknown as Awaited>); + }); const res = await GET(mockEvent('bucket=b1&key=path/data.csv')); @@ -47,12 +46,12 @@ describe('GET /storage/api/download', () => { }); it('uses application/octet-stream when no content type', async () => { - vi.mocked(downloadObject).mockResolvedValue({ + mockProvider.getObject.mockResolvedValue({ stream: new ReadableStream(), contentType: undefined, contentLength: undefined, etag: undefined - } as unknown as Awaited>); + }); const res = await GET(mockEvent('bucket=b1&key=file.bin')); expect(res.headers.get('Content-Type')).toBe('application/octet-stream'); @@ -60,14 +59,14 @@ describe('GET /storage/api/download', () => { }); }); -describe('HEAD /storage/api/download', () => { +describe('HEAD /api/storage/download', () => { beforeEach(() => vi.clearAllMocks()); it('returns 200 with metadata headers', async () => { - vi.mocked(getObjectMetadata).mockResolvedValue({ + mockProvider.getMetadata.mockResolvedValue({ contentType: 'application/json', size: 999 - } as unknown as Awaited>); + }); const res = await HEAD(mockEvent('bucket=b1&key=data.json')); diff --git a/src/routes/(app)/api/storage/list/+server.ts b/src/routes/(app)/api/storage/list/+server.ts new file mode 100644 index 00000000..69f105d7 --- /dev/null +++ b/src/routes/(app)/api/storage/list/+server.ts @@ -0,0 +1,34 @@ +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/storage/list?bucket=&prefix=&pageSize=&continuationToken= + * + * Lists objects in a bucket with cursor-based pagination. Returns the S3 + * list objects response directly. + */ +export const GET: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const prefix = event.url.searchParams.get('prefix')?.trim() ?? undefined; + const rawPageSize = event.url.searchParams.get('pageSize'); + const requestedPageSize = rawPageSize ? parseInt(rawPageSize, 10) : 25; + const pageSize = Number.isFinite(requestedPageSize) + ? Math.min(Math.max(requestedPageSize, 1), 1_000) + : 25; + const continuationToken = event.url.searchParams.get('continuationToken') || undefined; + const log = event.locals.logger; + + log.debug( + { + bucket, + prefix: prefix ?? '', + continuation_token: continuationToken, + page_size: pageSize + }, + 'listing objects' + ); + + const page = await provider.listObjects(prefix ?? '', pageSize, continuationToken); + + return Response.json(page); +}; diff --git a/src/routes/(app)/api/storage/list/server.test.ts b/src/routes/(app)/api/storage/list/server.test.ts new file mode 100644 index 00000000..08afbfde --- /dev/null +++ b/src/routes/(app)/api/storage/list/server.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockProvider = { listObjects: vi.fn() }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider +})); + +import { GET } from './+server.js'; + +function mockEvent(params: string) { + const url = new URL(`http://localhost/api/storage/list?${params}`); + return { + url, + request: { headers: new Headers() }, + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + storageConfig: { type: 's3', region: { name: 'us-east-1' } } + } + } as unknown as Parameters[0]; +} + +describe('GET /api/storage/list', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns paginated object listing', async () => { + const lastModified = new Date('2024-06-15T12:00:00Z'); + const page = { + contents: [{ key: 'a.txt', size: 10, lastModified }], + continuationToken: 'next-page', + isTruncated: true + }; + mockProvider.listObjects.mockResolvedValue(page); + + const res = await GET(mockEvent('bucket=b1')); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.contents).toHaveLength(1); + expect(body.contents[0].key).toBe('a.txt'); + expect(body.continuationToken).toBe('next-page'); + expect(body.isTruncated).toBe(true); + expect(mockProvider.listObjects).toHaveBeenCalledWith('', 25, undefined); + }); + + it('passes prefix and pageSize to provider', async () => { + mockProvider.listObjects.mockResolvedValue({ + contents: [], + continuationToken: null, + isTruncated: false + }); + + await GET(mockEvent('bucket=b1&prefix=data/&pageSize=10')); + + expect(mockProvider.listObjects).toHaveBeenCalledWith('data/', 10, undefined); + }); + + it('passes continuationToken to provider', async () => { + mockProvider.listObjects.mockResolvedValue({ + contents: [], + continuationToken: null, + isTruncated: false + }); + + await GET(mockEvent('bucket=b1&continuationToken=abc123')); + + expect(mockProvider.listObjects).toHaveBeenCalledWith('', 25, 'abc123'); + }); + + it('clamps pageSize to the supported range', async () => { + mockProvider.listObjects.mockResolvedValue({ + contents: [], + continuationToken: null, + isTruncated: false + }); + + await GET(mockEvent('bucket=b1&pageSize=1001')); + expect(mockProvider.listObjects).toHaveBeenLastCalledWith('', 1_000, undefined); + + await GET(mockEvent('bucket=b1&pageSize=0')); + expect(mockProvider.listObjects).toHaveBeenLastCalledWith('', 1, undefined); + + await GET(mockEvent('bucket=b1&pageSize=not-a-number')); + expect(mockProvider.listObjects).toHaveBeenLastCalledWith('', 25, undefined); + }); + + it('throws when bucket is missing', async () => { + const url = new URL('http://localhost/api/storage/list'); + const event = { + url, + request: { headers: new Headers() }, + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + storageConfig: { type: 's3', region: { name: 'us-east-1' } } + } + } as unknown as Parameters[0]; + + await expect(GET(event)).rejects.toThrow(); + }); +}); diff --git a/src/routes/(app)/api/storage/move/+server.ts b/src/routes/(app)/api/storage/move/+server.ts new file mode 100644 index 00000000..f0165a89 --- /dev/null +++ b/src/routes/(app)/api/storage/move/+server.ts @@ -0,0 +1,49 @@ +import type { RequestHandler } from './$types'; +import { error } from '@sveltejs/kit'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import { performCopyOrMove } from '$lib/server/storage/copy-move.js'; + +export const POST: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const streamProgress = event.url.searchParams.get('progress') === 'true'; + const { locals, request } = event; + + const body = (await request.json()) as { + sourceKeys: string[]; + destinationPrefix?: string; + destinationKey?: string; + jobId?: string; + }; + if (!body.sourceKeys?.length) { + throw error(400, 'Missing required body field: sourceKeys'); + } + if (body.destinationPrefix === undefined && !body.destinationKey) { + throw error(400, 'Missing required body field: destinationPrefix or destinationKey'); + } + if (body.destinationKey && body.sourceKeys.length !== 1) { + throw error(400, 'destinationKey requires exactly one source key'); + } + + locals.logger.debug( + { + bucket, + source_key_count: body.sourceKeys.length, + destination_prefix: body.destinationPrefix, + destination_key: body.destinationKey, + stream_progress: streamProgress + }, + 'move request received' + ); + + return performCopyOrMove({ + provider, + sourceKeys: body.sourceKeys, + destinationPrefix: body.destinationPrefix ?? '', + destinationKey: body.destinationKey, + streamProgress, + logger: locals.logger, + bucket, + jobId: body.jobId, + deleteOriginals: true + }); +}; diff --git a/src/routes/(app)/api/storage/move/server.test.ts b/src/routes/(app)/api/storage/move/server.test.ts new file mode 100644 index 00000000..23e1ab10 --- /dev/null +++ b/src/routes/(app)/api/storage/move/server.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { mockPerformCopyOrMove } = vi.hoisted(() => ({ mockPerformCopyOrMove: vi.fn() })); +vi.mock('$lib/server/storage/copy-move.js', () => ({ + performCopyOrMove: mockPerformCopyOrMove +})); +vi.mock('$lib/server/storage/request-context.js', () => ({ + createStorageProvider: () => ({ provider: {}, bucket: 'bucket' }) +})); + +import { POST } from './+server.js'; + +function mockEvent(body: unknown) { + return { + request: new Request('http://localhost/api/storage/move?bucket=bucket', { + method: 'POST', + body: JSON.stringify(body) + }), + url: new URL('http://localhost/api/storage/move?bucket=bucket'), + locals: { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() } } + } as unknown as Parameters[0]; +} + +describe('POST /api/storage/move', () => { + it('accepts an explicit destination key for a single-item rename', async () => { + mockPerformCopyOrMove.mockResolvedValue(new Response()); + + await POST(mockEvent({ sourceKeys: ['old.txt'], destinationKey: 'new.txt' })); + + expect(mockPerformCopyOrMove).toHaveBeenCalledWith( + expect.objectContaining({ + sourceKeys: ['old.txt'], + destinationPrefix: '', + destinationKey: 'new.txt', + deleteOriginals: true + }) + ); + }); + + it('rejects an explicit destination key for multiple source keys', async () => { + await expect( + POST(mockEvent({ sourceKeys: ['first.txt', 'second.txt'], destinationKey: 'new.txt' })) + ).rejects.toThrow(expect.objectContaining({ status: 400 })); + }); +}); diff --git a/src/routes/(app)/api/storage/objects/+server.ts b/src/routes/(app)/api/storage/objects/+server.ts deleted file mode 100644 index cad1ed02..00000000 --- a/src/routes/(app)/api/storage/objects/+server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { listObjects } from '$lib/server/storage/service.js'; -import { allowedPageSizes } from '$lib/client/feature-flags.js'; -import type { RequestHandler } from './$types'; - -/** - * GET /storage/api/objects?bucket=&prefix=&pageSize=&continuationToken= - * - * Returns a page of objects in the given bucket/prefix using the connection - * config supplied in the `X-Storage-Connection` request header. - * The config is parsed and validated by the `handleStorageConnection` middleware - * in hooks.server.ts before this handler runs. - */ -export const GET: RequestHandler = async ({ url, locals }) => { - const bucket = url.searchParams.get('bucket')?.trim(); - if (!bucket) throw error(400, 'Missing required query parameter: bucket'); - - const prefix = url.searchParams.get('prefix') ?? ''; - const continuationToken = url.searchParams.get('continuationToken'); - const pageSizeParam = url.searchParams.get('pageSize'); - const parsed = pageSizeParam ? parseInt(pageSizeParam, 10) : NaN; - const pageSize = allowedPageSizes.includes(parsed) ? parsed : allowedPageSizes[0]; - - locals.logger.debug( - { bucket, prefix, continuation_token: continuationToken, page_size: pageSize }, - 'listing objects' - ); - - const page = await listObjects( - locals.storageConfig!, - bucket, - prefix, - pageSize, - continuationToken - ); - return Response.json(page); -}; diff --git a/src/routes/(app)/api/storage/params.test.ts b/src/routes/(app)/api/storage/params.test.ts deleted file mode 100644 index f8625d32..00000000 --- a/src/routes/(app)/api/storage/params.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { requireBucket, requireBucketKey } from './params.js'; - -describe('requireBucket', () => { - it('throws 400 when bucket param is missing', () => { - const url = new URL('http://localhost/storage/api/test'); - expect(() => requireBucket(url)).toThrow(expect.objectContaining({ status: 400 })); - }); - - it('throws 400 when bucket param is empty', () => { - const url = new URL('http://localhost/storage/api/test?bucket='); - expect(() => requireBucket(url)).toThrow(expect.objectContaining({ status: 400 })); - }); - - it('throws 400 when bucket param is whitespace only', () => { - const url = new URL('http://localhost/storage/api/test?bucket=%20%20'); - expect(() => requireBucket(url)).toThrow(expect.objectContaining({ status: 400 })); - }); - - it('returns trimmed bucket name', () => { - const url = new URL('http://localhost/storage/api/test?bucket=%20my-bucket%20'); - expect(requireBucket(url)).toBe('my-bucket'); - }); -}); - -describe('requireBucketKey', () => { - it('throws 400 when bucket is missing', () => { - const url = new URL('http://localhost/storage/api/test?key=file.txt'); - expect(() => requireBucketKey(url)).toThrow(expect.objectContaining({ status: 400 })); - }); - - it('throws 400 when key is missing', () => { - const url = new URL('http://localhost/storage/api/test?bucket=my-bucket'); - expect(() => requireBucketKey(url)).toThrow(expect.objectContaining({ status: 400 })); - }); - - it('throws 400 when key is empty', () => { - const url = new URL('http://localhost/storage/api/test?bucket=my-bucket&key='); - expect(() => requireBucketKey(url)).toThrow(expect.objectContaining({ status: 400 })); - }); - - it('returns both bucket and key trimmed', () => { - const url = new URL( - 'http://localhost/storage/api/test?bucket=%20b1%20&key=%20path/file.txt%20' - ); - expect(requireBucketKey(url)).toEqual({ bucket: 'b1', key: 'path/file.txt' }); - }); -}); diff --git a/src/routes/(app)/api/storage/params.ts b/src/routes/(app)/api/storage/params.ts deleted file mode 100644 index 3d6cbd53..00000000 --- a/src/routes/(app)/api/storage/params.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { error } from '@sveltejs/kit'; - -/** - * Extracts and validates the `bucket` query parameter from a storage API - * request URL. Throws a 400 error if absent or blank. - */ -export function requireBucket(url: URL): string { - const bucket = url.searchParams.get('bucket')?.trim(); - if (!bucket) { - throw error(400, 'Missing required query parameter: bucket'); - } - return bucket; -} - -/** - * Extracts and validates the `bucket` and `key` query parameters from a - * storage API request URL. Throws a 400 error if either is absent or blank. - */ -export function requireBucketKey(url: URL): { bucket: string; key: string } { - const bucket = requireBucket(url); - - const key = url.searchParams.get('key')?.trim(); - if (!key) { - throw error(400, 'Missing required query parameter: key'); - } - - return { bucket, key }; -} diff --git a/src/routes/(app)/api/storage/preview/+server.ts b/src/routes/(app)/api/storage/preview/+server.ts index ffb51b24..d031a8bf 100644 --- a/src/routes/(app)/api/storage/preview/+server.ts +++ b/src/routes/(app)/api/storage/preview/+server.ts @@ -1,23 +1,26 @@ +import { error } from '@sveltejs/kit'; import { S3ServiceException } from '@aws-sdk/client-s3'; import { mapS3ErrorToHttp } from '$lib/server/storage/s3-errors.js'; -import { getProvider } from '$lib/server/storage/utils.js'; -// import { parquetPreview } from '$lib/server/storage/preview/parquet.js'; +import { getParquetPreview } from '$lib/server/storage/preview/parquet'; +import { getCsvPreview } from '$lib/server/storage/preview/csv'; import { binaryPreview, KNOWN_BINARY_TYPES } from '$lib/server/storage/preview/binary.js'; import { streamPreview } from '$lib/server/storage/preview/stream.js'; -import { requireBucketKey } from '../params.js'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import { infiniteScrollEnabled, filePreviewRows } from '$lib/server/feature-flags'; import type { RequestHandler } from './$types'; /** - * GET /storage/api/preview?bucket=&key= + * GET /api/storage/preview?bucket=&key= * * The connection config is parsed and validated by the `handleStorageConnection` * middleware in hooks.server.ts before this handler runs. */ -export const GET: RequestHandler = async ({ url, locals }) => { +export const GET: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const key = event.url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); + const { url, locals } = event; const log = locals.logger; - const { bucket, key } = requireBucketKey(url); - - const provider = getProvider(locals.storageConfig!, bucket); try { const metadata = await provider.getMetadata(key); @@ -25,19 +28,33 @@ export const GET: RequestHandler = async ({ url, locals }) => { const totalSize = metadata.size; const lowerKey = key.toLowerCase(); - // Parquet files (by content-type or extension) — parse server-side and emit CSV rows. + // Parquet files (by content-type or extension) — parse server-side and emit structured preview rows. const isParquet = rawContentType === 'application/vnd.apache.parquet' || rawContentType === 'application/x-parquet' || lowerKey.endsWith('.parquet'); + let offset = parseInt(url.searchParams.get('offset') ?? '0', 10); + let limit = parseInt(url.searchParams.get('limit') ?? '250', 10); + const includeData = url.searchParams.get('data') === 'true'; + + // When infinite scroll is disabled, restrict to the first page only (no chunked loading) + if (!infiniteScrollEnabled) { + offset = 0; + limit = Math.min(limit, filePreviewRows); + } + if (isParquet) { - log.info( - { bucket, key, content_type: rawContentType, total_size: totalSize }, - 'parquet preview disabled' + return await getParquetPreview( + provider, + key, + offset, + limit, + log, + totalSize, + includeData, + bucket ); - // TODO: re-enable once we have a more robust parquet preview solution in place - // return await parquetPreview(provider, key, totalSize, userId, log); } // Skip body fetch for known-binary formats — client will show fallback immediately. @@ -49,12 +66,22 @@ export const GET: RequestHandler = async ({ url, locals }) => { return binaryPreview(rawContentType, totalSize); } - // Normalise the content-type for Excel-exported CSV files so the client - // treats them as text/csv rather than binary. - const contentType = - rawContentType === 'application/vnd.ms-excel' && lowerKey.endsWith('.csv') - ? 'text/csv' - : rawContentType; + // Normalise the content-type for Excel-exported CSV/TSV files so the client + // treats them as text/csv or text/tab-separated-values rather than binary. + let contentType = rawContentType; + if (rawContentType === 'application/vnd.ms-excel') { + if (lowerKey.endsWith('.csv')) contentType = 'text/csv'; + else if (lowerKey.endsWith('.tsv')) contentType = 'text/tab-separated-values'; + } + + // CSV files use row-based NDJSON streaming. TSV is deliberately handled by + // the text preview below so CsvPreview can parse its tab delimiter directly. + const isCsv = + contentType === 'text/csv' || contentType === 'application/csv' || lowerKey.endsWith('.csv'); + + if (isCsv) { + return await getCsvPreview(provider, key, offset, limit, totalSize, log, includeData, bucket); + } // Pass a placeholder user identifier for logging purposes (no longer user-specific) return await streamPreview(provider, key, contentType, totalSize, 'client', log); diff --git a/src/routes/(app)/api/storage/preview/server.test.ts b/src/routes/(app)/api/storage/preview/server.test.ts index 10c18fdd..4b1f1921 100644 --- a/src/routes/(app)/api/storage/preview/server.test.ts +++ b/src/routes/(app)/api/storage/preview/server.test.ts @@ -6,6 +6,10 @@ vi.mock('$lib/server/storage/utils.js', () => ({ getProvider: vi.fn(() => ({ getMetadata: mockGetMetadata })) })); +vi.mock('$lib/server/storage/wrap-provider.js', () => ({ + withStorageHttpErrors: (p: unknown) => p +})); + vi.mock('$lib/server/storage/preview/binary.js', () => ({ KNOWN_BINARY_TYPES: new Set(['application/zip']), binaryPreview: vi.fn(() => new Response(null, { headers: { 'X-Preview-Renderable': 'false' } })) @@ -15,6 +19,38 @@ vi.mock('$lib/server/storage/preview/stream.js', () => ({ streamPreview: vi.fn(async () => new Response('preview content')) })); +vi.mock('$lib/server/feature-flags', () => ({ + infiniteScrollEnabled: false, + filePreviewRows: 250, + textPreviewBytes: 262144, + imagePreviewBytes: 5242880, + pdfPreviewBytes: 26214400, + archivePreviewMaxBytes: 104857600, + maxEditableFileSize: 5242880, + parquetDisallowedCompression: [], + completionEnabled: true, + storageBrowserEnabled: true +})); + +vi.mock('$lib/server/storage/preview/csv.js', () => ({ + getCsvPreview: vi.fn(async () => { + const body = + JSON.stringify({ t: 'h', h: ['a', 'b'] }) + '\n' + JSON.stringify({ t: 'd' }) + '\n'; + return new Response(body, { + headers: { 'X-Preview-Format': 'csv', 'X-Preview-Renderable': 'true' } + }); + }) +})); + +vi.mock('$lib/server/storage/preview/parquet.js', () => ({ + getParquetPreview: vi.fn(async () => { + const body = JSON.stringify({ headers: ['a'], rows: [['1']], totalRows: 1 }); + return new Response(body, { + headers: { 'X-Preview-Format': 'parquet', 'X-Preview-Renderable': 'true' } + }); + }) +})); + vi.mock('$lib/server/storage/s3-errors.js', () => ({ mapS3ErrorToHttp: vi.fn((err) => { throw err; @@ -24,6 +60,8 @@ vi.mock('$lib/server/storage/s3-errors.js', () => ({ import { GET } from './+server.js'; import { binaryPreview } from '$lib/server/storage/preview/binary.js'; import { streamPreview } from '$lib/server/storage/preview/stream.js'; +import { getParquetPreview } from '$lib/server/storage/preview/parquet.js'; +import { getCsvPreview } from '$lib/server/storage/preview/csv.js'; import { mapS3ErrorToHttp } from '$lib/server/storage/s3-errors.js'; const CONNECTION_HEADER = { @@ -31,19 +69,19 @@ const CONNECTION_HEADER = { }; function mockEvent(params: string) { - const url = new URL(`http://localhost/storage/api/preview?${params}`); + const url = new URL(`http://localhost/api/storage/preview?${params}`); return { url, request: { headers: new Headers(CONNECTION_HEADER) }, locals: { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, user: { id: 'test-user' }, - storageConfig: { type: 's3', region: 'us-east-1' } + storageConfig: { type: 's3', region: { name: 'us-east-1' } } } } as unknown as Parameters[0]; } -describe('GET /storage/api/preview', () => { +describe('GET /api/storage/preview', () => { beforeEach(() => vi.clearAllMocks()); it('returns binary preview for known binary types', async () => { @@ -59,11 +97,79 @@ describe('GET /storage/api/preview', () => { await GET(mockEvent('bucket=b1&key=data.csv')); - expect(streamPreview).toHaveBeenCalledWith( + expect(getCsvPreview).toHaveBeenCalledWith( expect.anything(), 'data.csv', - 'text/csv', + 0, + 250, 100, + expect.anything(), + false, + 'b1' + ); + }); + + it('normalises Excel TSV content type for text preview', async () => { + mockGetMetadata.mockResolvedValue({ contentType: 'application/vnd.ms-excel', size: 200 }); + + await GET(mockEvent('bucket=b1&key=data.tsv')); + + expect(streamPreview).toHaveBeenCalledWith( + expect.anything(), + 'data.tsv', + 'text/tab-separated-values', + 200, + 'client', + expect.anything() + ); + }); + + it('does not normalise Excel content type for non-CSV/TSV extensions', async () => { + mockGetMetadata.mockResolvedValue({ contentType: 'application/vnd.ms-excel', size: 300 }); + + await GET(mockEvent('bucket=b1&key=workbook.xls')); + + expect(streamPreview).toHaveBeenCalledWith( + expect.anything(), + 'workbook.xls', + 'application/vnd.ms-excel', + 300, + 'client', + expect.anything() + ); + }); + + it('passes text/tab-separated-values content type through unchanged', async () => { + mockGetMetadata.mockResolvedValue({ + contentType: 'text/tab-separated-values', + size: 150 + }); + + await GET(mockEvent('bucket=b1&key=data.tsv')); + + expect(streamPreview).toHaveBeenCalledWith( + expect.anything(), + 'data.tsv', + 'text/tab-separated-values', + 150, + 'client', + expect.anything() + ); + }); + + it('uses text preview for TSV files with generic content types', async () => { + mockGetMetadata.mockResolvedValue({ + contentType: 'application/octet-stream', + size: 75 + }); + + await GET(mockEvent('bucket=b1&key=report.tsv')); + + expect(streamPreview).toHaveBeenCalledWith( + expect.anything(), + 'report.tsv', + 'application/octet-stream', + 75, 'client', expect.anything() ); @@ -84,6 +190,88 @@ describe('GET /storage/api/preview', () => { ); }); + it('detects parquet by content-type and delegates to getParquetPreview', async () => { + mockGetMetadata.mockResolvedValue({ + contentType: 'application/vnd.apache.parquet', + size: 5000 + }); + + const res = await GET(mockEvent('bucket=b1&key=data.parquet')); + + expect(getParquetPreview).toHaveBeenCalledWith( + expect.anything(), + 'data.parquet', + 0, + 250, + expect.anything(), + 5000, + false, + 'b1' + ); + expect(res.headers.get('X-Preview-Format')).toBe('parquet'); + }); + + it('detects parquet by .parquet extension', async () => { + mockGetMetadata.mockResolvedValue({ + contentType: 'application/octet-stream', + size: 5000 + }); + + const res = await GET(mockEvent('bucket=b1&key=measurements.parquet')); + + expect(getParquetPreview).toHaveBeenCalledWith( + expect.anything(), + 'measurements.parquet', + 0, + 250, + expect.anything(), + 5000, + false, + 'b1' + ); + expect(res.headers.get('X-Preview-Format')).toBe('parquet'); + }); + + it('resets offset and caps limit when infinite scroll is disabled', async () => { + mockGetMetadata.mockResolvedValue({ + contentType: 'application/x-parquet', + size: 50000 + }); + + await GET(mockEvent('bucket=b1&key=large.parquet&offset=500&limit=100')); + + expect(getParquetPreview).toHaveBeenCalledWith( + expect.anything(), + 'large.parquet', + 0, + 100, + expect.anything(), + 50000, + false, + 'b1' + ); + }); + + it('handles zero-size parquet file gracefully', async () => { + mockGetMetadata.mockResolvedValue({ + contentType: 'application/vnd.apache.parquet', + size: 0 + }); + + await GET(mockEvent('bucket=b1&key=empty.parquet')); + + expect(getParquetPreview).toHaveBeenCalledWith( + expect.anything(), + 'empty.parquet', + 0, + 250, + expect.anything(), + 0, + false, + 'b1' + ); + }); + it('maps S3 errors to HTTP errors', async () => { const s3Err = new S3ServiceException({ name: 'NoSuchKey', diff --git a/src/routes/(app)/api/storage/save-text/+server.ts b/src/routes/(app)/api/storage/save-text/+server.ts new file mode 100644 index 00000000..39ce43a0 --- /dev/null +++ b/src/routes/(app)/api/storage/save-text/+server.ts @@ -0,0 +1,134 @@ +import { error } from '@sveltejs/kit'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; +import { maxEditableFileSize } from '$lib/server/feature-flags.js'; +import type { RequestHandler } from './$types'; + +/** + * POST /api/storage/save-text?bucket=&key=&originalSize=&previewBytes=&contentType= + * + * Saves edited text content for a storage object. The request body is the raw + * text to save. When `previewBytes < originalSize`, the tail of the original + * file is fetched and merged with the edited portion so only the beginning of + * the file was transmitted to the client for editing. + * + * The connection config is parsed and validated by the `handleStorageConnection` + * middleware in hooks.server.ts before this handler runs. + */ +export const POST: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const key = event.url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); + const log = event.locals.logger; + + const contentType = event.url.searchParams.get('contentType')?.trim() || 'text/plain'; + const rawOriginalSize = event.url.searchParams.get('originalSize'); + const rawPreviewBytes = event.url.searchParams.get('previewBytes'); + const originalSize = rawOriginalSize ? parseInt(rawOriginalSize, 10) : NaN; + const previewBytes = rawPreviewBytes ? parseInt(rawPreviewBytes, 10) : originalSize; + + if (!Number.isFinite(originalSize) || originalSize < 0) { + throw error(400, 'Invalid originalSize: must be a non-negative integer'); + } + if (!Number.isFinite(previewBytes) || previewBytes < 0) { + throw error(400, 'Invalid previewBytes: must be a non-negative integer'); + } + + if (!event.request.body) throw error(400, 'Missing request body'); + + if (originalSize > maxEditableFileSize) { + log.warn( + { + bucket, + key, + original_size: originalSize, + max_editable_size: maxEditableFileSize + }, + 'save-text rejected: file exceeds max editable size (read-only)' + ); + throw error(413, 'File exceeds the maximum editable size and is read-only'); + } + + const truncated = previewBytes > 0 && previewBytes < originalSize; + + log.debug( + { + bucket, + key, + content_type: contentType, + original_size: originalSize, + preview_bytes: previewBytes, + truncated + }, + 'save-text request received' + ); + + const editReader = event.request.body.getReader(); + const editChunks: Uint8Array[] = []; + let totalEditBytes = 0; + while (true) { + const { done, value } = await editReader.read(); + if (done) break; + editChunks.push(value); + totalEditBytes += value.length; + if (totalEditBytes > maxEditableFileSize) { + editReader.cancel(); + throw error(413, 'File exceeds the maximum editable size and is read-only'); + } + } + + let mergedBuffer: Buffer; + let totalLength: number; + + if (truncated) { + const tailStream = await provider.getObjectRange(key, previewBytes, originalSize - 1); + const tailReader = tailStream.getReader(); + const tailChunks: Uint8Array[] = []; + let totalTailBytes = 0; + while (true) { + const { done, value } = await tailReader.read(); + if (done) break; + tailChunks.push(value); + totalTailBytes += value.length; + } + + const merged = new Uint8Array(totalEditBytes + totalTailBytes); + let offset = 0; + for (const chunk of editChunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + for (const chunk of tailChunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + + mergedBuffer = Buffer.from(merged.buffer); + totalLength = merged.length; + + log.info( + { + bucket, + key, + edit_bytes: totalEditBytes, + tail_bytes: totalTailBytes, + total_bytes: totalLength + }, + 'saving truncated file with tail merge' + ); + } else { + const merged = new Uint8Array(totalEditBytes); + let offset = 0; + for (const chunk of editChunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + mergedBuffer = Buffer.from(merged.buffer); + totalLength = totalEditBytes; + } + + await provider.putObject(key, mergedBuffer, contentType, totalLength); + + log.info({ bucket, key }, 'save-text completed'); + + return new Response(null, { status: 200 }); +}; diff --git a/src/routes/(app)/api/storage/save-text/server.test.ts b/src/routes/(app)/api/storage/save-text/server.test.ts new file mode 100644 index 00000000..ec5e1a64 --- /dev/null +++ b/src/routes/(app)/api/storage/save-text/server.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockProvider = { putObject: vi.fn(), getObjectRange: vi.fn() }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider +})); + +vi.mock('$lib/server/feature-flags.js', () => ({ + maxEditableFileSize: 5242880 +})); + +import { POST } from './+server.js'; + +const CONNECTION_HEADER = { + 'x-storage-connection': btoa(JSON.stringify({ type: 's3', region: 'us-east-1' })) +}; + +function mockEvent(params: string, body?: ReadableStream | null) { + const url = new URL(`http://localhost/api/storage/save-text?${params}`); + return { + url, + request: { + headers: new Headers(CONNECTION_HEADER), + body: body ?? null + }, + locals: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + user: { id: 'test-user' }, + storageConfig: { type: 's3', region: { name: 'us-east-1' } } + } + } as unknown as Parameters[0]; +} + +function textBody(text: string): ReadableStream { + const encoder = new TextEncoder(); + const bytes = encoder.encode(text); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + } + }); +} + +describe('POST /api/storage/save-text', () => { + beforeEach(() => vi.clearAllMocks()); + + it('saves text body without truncation', async () => { + mockProvider.putObject.mockResolvedValue(undefined); + const body = textBody('hello world'); + + const res = await POST( + mockEvent('bucket=b1&key=file.txt&originalSize=11&previewBytes=11', body) + ); + + expect(res.status).toBe(200); + expect(mockProvider.putObject).toHaveBeenCalledWith( + 'file.txt', + expect.anything(), + 'text/plain', + 11 + ); + expect(mockProvider.getObjectRange).not.toHaveBeenCalled(); + }); + + it('saves newly created empty files', async () => { + mockProvider.putObject.mockResolvedValue(undefined); + + const res = await POST( + mockEvent('bucket=b1&key=file.txt&originalSize=0&previewBytes=0', textBody('new text')) + ); + + expect(res.status).toBe(200); + expect(mockProvider.putObject).toHaveBeenCalledWith( + 'file.txt', + expect.anything(), + 'text/plain', + 8 + ); + }); + + it('merges edit tail when truncated', async () => { + const tailBytes = new TextEncoder().encode('...tail'); + const tailStream = new ReadableStream({ + start(controller) { + controller.enqueue(tailBytes); + controller.close(); + } + }); + mockProvider.getObjectRange.mockResolvedValue(tailStream); + mockProvider.putObject.mockResolvedValue(undefined); + + const body = textBody('edited'); + + const res = await POST( + mockEvent('bucket=b1&key=file.txt&originalSize=100&previewBytes=10', body) + ); + + expect(res.status).toBe(200); + expect(mockProvider.getObjectRange).toHaveBeenCalledWith('file.txt', 10, 99); + expect(mockProvider.putObject).toHaveBeenCalledWith( + 'file.txt', + expect.anything(), + 'text/plain', + 13 // 6 ("edited") + 7 ("...tail") + ); + }); + + it('returns 400 when key is missing', async () => { + await expect(POST(mockEvent('bucket=b1&originalSize=10'))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('returns 400 when originalSize is invalid', async () => { + await expect(POST(mockEvent('bucket=b1&key=file.txt&originalSize=abc'))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('returns 400 when request body is missing', async () => { + await expect(POST(mockEvent('bucket=b1&key=file.txt&originalSize=10'))).rejects.toThrow( + expect.objectContaining({ status: 400 }) + ); + }); + + it('returns 413 when file exceeds max editable size', async () => { + const body = textBody('content'); + + await expect( + POST(mockEvent('bucket=b1&key=file.txt&originalSize=6000000', body)) + ).rejects.toThrow(expect.objectContaining({ status: 413 })); + }); + + it('cancels read when body exceeds max size mid-stream', async () => { + mockProvider.putObject.mockResolvedValue(undefined); + + const largeChunk = new Uint8Array(3_000_000); + const smallChunk = new Uint8Array(3_000_000); + let callCount = 0; + const body = new ReadableStream({ + pull(controller) { + if (callCount === 0) { + controller.enqueue(largeChunk); + callCount++; + } else if (callCount === 1) { + controller.enqueue(smallChunk); + callCount++; + } else { + controller.close(); + } + } + }); + + await expect( + POST(mockEvent('bucket=b1&key=file.txt&originalSize=7000000', body)) + ).rejects.toThrow(expect.objectContaining({ status: 413 })); + }); +}); diff --git a/src/routes/(app)/api/storage/upload/+server.ts b/src/routes/(app)/api/storage/upload/+server.ts index b6202da4..e7214bde 100644 --- a/src/routes/(app)/api/storage/upload/+server.ts +++ b/src/routes/(app)/api/storage/upload/+server.ts @@ -1,10 +1,9 @@ import { error } from '@sveltejs/kit'; -import { uploadObject } from '$lib/server/storage/service.js'; -import { requireBucketKey } from '../params.js'; +import { createStorageProvider } from '$lib/server/storage/request-context.js'; import type { RequestHandler } from './$types'; /** - * POST /storage/api/upload?bucket=&key= + * POST /api/storage/upload?bucket=&key= * * Streams an uploaded file directly to S3 using multipart upload (via * @aws-sdk/lib-storage). The request body is piped to the S3 SDK without @@ -13,9 +12,12 @@ import type { RequestHandler } from './$types'; * The connection config is parsed and validated by the `handleStorageConnection` * middleware in hooks.server.ts before this handler runs. */ -export const POST: RequestHandler = async ({ locals, url, request }) => { +export const POST: RequestHandler = async (event) => { + const { provider, bucket } = createStorageProvider(event); + const key = event.url.searchParams.get('key')?.trim(); + if (!key) throw error(400, 'Missing required query parameter: key'); + const { locals, request } = event; const log = locals.logger; - const { bucket, key } = requireBucketKey(url); if (!request.body) { throw error(400, 'Missing request body'); @@ -33,7 +35,7 @@ export const POST: RequestHandler = async ({ locals, url, request }) => { 'upload request received' ); - await uploadObject(locals.storageConfig!, bucket, key, request.body, contentType, contentLength); + await provider.putObject(key, request.body, contentType, contentLength); log.info( { bucket, key, content_type: contentType, content_length: contentLength }, diff --git a/src/routes/(app)/api/storage/upload/server.test.ts b/src/routes/(app)/api/storage/upload/server.test.ts index 17644c13..c98045d9 100644 --- a/src/routes/(app)/api/storage/upload/server.test.ts +++ b/src/routes/(app)/api/storage/upload/server.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('$lib/server/storage/service.js', () => ({ - uploadObject: vi.fn() +const mockProvider = { putObject: vi.fn() }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getProvider: () => mockProvider })); import { POST } from './+server.js'; -import { uploadObject } from '$lib/server/storage/service.js'; const CONNECTION_HEADER = { 'x-storage-connection': btoa(JSON.stringify({ type: 's3', region: 'us-east-1' })) @@ -17,7 +17,7 @@ function mockEvent(opts: { headers?: Record; }) { const url = new URL( - `http://localhost/storage/api/upload?${opts.params ?? 'bucket=b1&key=file.txt'}` + `http://localhost/api/storage/upload?${opts.params ?? 'bucket=b1&key=file.txt'}` ); const headers = new Headers( opts.headers ?? { 'Content-Type': 'text/plain', 'Content-Length': '42', ...CONNECTION_HEADER } @@ -29,12 +29,12 @@ function mockEvent(opts: { locals: { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, user: { id: 'test-user' }, - storageConfig: { type: 's3', region: 'us-east-1' } + storageConfig: { type: 's3', region: { name: 'us-east-1' } } } } as unknown as Parameters[0]; } -describe('POST /storage/api/upload', () => { +describe('POST /api/storage/upload', () => { beforeEach(() => vi.clearAllMocks()); it('throws 400 when bucket/key missing', async () => { @@ -50,7 +50,7 @@ describe('POST /storage/api/upload', () => { }); it('uploads and returns 201', async () => { - vi.mocked(uploadObject).mockResolvedValue(undefined); + mockProvider.putObject.mockResolvedValue(undefined); const body = new ReadableStream(); const res = await POST( @@ -65,22 +65,15 @@ describe('POST /storage/api/upload', () => { ); expect(res.status).toBe(201); - expect(uploadObject).toHaveBeenCalledWith( - expect.objectContaining({ type: 's3' }), - 'b1', - 'file.txt', - body, - 'image/png', - 100 - ); + expect(mockProvider.putObject).toHaveBeenCalledWith('file.txt', body, 'image/png', 100); }); it('defaults content type to application/octet-stream', async () => { - vi.mocked(uploadObject).mockResolvedValue(undefined); + mockProvider.putObject.mockResolvedValue(undefined); const res = await POST(mockEvent({ headers: { ...CONNECTION_HEADER } })); expect(res.status).toBe(201); - expect(vi.mocked(uploadObject).mock.calls[0][4]).toBe('application/octet-stream'); + expect(mockProvider.putObject.mock.calls[0][2]).toBe('application/octet-stream'); }); }); diff --git a/src/routes/(app)/settings/connections/+page.server.ts b/src/routes/(app)/settings/connections/+page.server.ts new file mode 100644 index 00000000..5e291198 --- /dev/null +++ b/src/routes/(app)/settings/connections/+page.server.ts @@ -0,0 +1,74 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { superValidate } from 'sveltekit-superforms'; +import { zod4 as zod } from 'sveltekit-superforms/adapters'; +import { ConnectionIdSchema } from '$lib/storage/schemas.js'; +import { deleteConnection } from '$lib/server/storage/connections-db.js'; +import { auth } from '$lib/server/auth.js'; +import { desc, eq } from 'drizzle-orm'; +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'; + +export const load: PageServerLoad = async ({ locals }) => { + locals.logger.debug('loading storage connections management page'); + const rows = await db + .select() + .from(userStorageConnections) + .where(eq(userStorageConnections.userId, locals.user!.id)) + .orderBy(desc(userStorageConnections.updatedAt)); + + const connections: ConnectionListItem[] = rows.map((row) => { + let endpoint: string | null = null; + try { + const payload = JSON.parse(decrypt(row.encryptedPayload, storageEncryptionKey())) as { + host?: string; + port?: number; + }; + endpoint = + payload.host && payload.port ? `${payload.host}:${payload.port}` : (payload.host ?? null); + } catch (err) { + locals.logger.warn({ err, connection_id: row.id }, 'failed to decrypt storage connection'); + } + return { + id: row.id, + name: row.name, + endpoint, + additionalBuckets: (row.additionalBuckets as string[]) ?? [], + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString() + }; + }); + + return { connections }; +}; + +export const actions: Actions = { + 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); + log.info({ connectionId }, 'storage connection deleted from management page'); + + throw redirect(303, '/settings/connections'); + } +}; diff --git a/src/routes/(app)/storage/connections/+page.svelte b/src/routes/(app)/settings/connections/+page.svelte similarity index 60% rename from src/routes/(app)/storage/connections/+page.svelte rename to src/routes/(app)/settings/connections/+page.svelte index 7f5218b3..fc765ea9 100644 --- a/src/routes/(app)/storage/connections/+page.svelte +++ b/src/routes/(app)/settings/connections/+page.svelte @@ -1,5 +1,5 @@ @@ -93,7 +85,7 @@
    - + @@ -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..1aa091a2 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/server/storage/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..7c2bcbb2 --- /dev/null +++ b/src/routes/(app)/settings/connections/[id]/edit/+page.server.ts @@ -0,0 +1,179 @@ +import { error, fail, redirect, isHttpError } from '@sveltejs/kit'; +import { superValidate, message } 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 } from '$lib/storage/schemas.js'; +import { 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 } from '$lib/server/storage/encryption.js'; +import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js'; +import { logger } from '$lib/server/logging'; +import * as m from '$lib/paraglide/messages.js'; + +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 = 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, 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, msg, { status: 400 }); + } + } + + // If no new credentials were provided, preserve the existing encrypted ones. + let payload: object; + 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, 'Connection not found', { status: 404 }); + } + const existing = JSON.parse(decrypt(rows[0].encryptedPayload, storageEncryptionKey())) as { + host: string; + port?: number; + tls?: object; + accessStyle: string; + region: object; + credentials?: { accessKey: string; secretKey: string }; + }; + payload = { host, port, tls, accessStyle, region, credentials: existing.credentials }; + } catch { + return message(form, 'Failed to read existing credentials', { status: 500 }); + } + } + + const encryptedPayload = encrypt(JSON.stringify(payload), storageEncryptionKey()); + + await db + .update(userStorageConnections) + .set({ encryptedPayload, name: form.data.name ?? undefined }) + .where( + and(eq(userStorageConnections.id, connectionId), eq(userStorageConnections.userId, userId)) + ); + + log.info({ connection_id: connectionId }, 'storage connection updated'); + + return message(form, 'ok'); + } +}; diff --git a/src/routes/(app)/storage/connections/[id]/edit/+page.svelte b/src/routes/(app)/settings/connections/[id]/edit/+page.svelte similarity index 77% rename from src/routes/(app)/storage/connections/[id]/edit/+page.svelte rename to src/routes/(app)/settings/connections/[id]/edit/+page.svelte index e09ebb98..77a35600 100644 --- a/src/routes/(app)/storage/connections/[id]/edit/+page.svelte +++ b/src/routes/(app)/settings/connections/[id]/edit/+page.svelte @@ -2,7 +2,7 @@ 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 { untrack } from 'svelte'; @@ -10,32 +10,20 @@ 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), { @@ -43,20 +31,9 @@ validators: zod(EditStorageConnectionSchema), onResult: ({ result, cancel }) => { if (result.type === 'success' && result.data?.form?.message === 'ok') { - 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. cancel(); - // Bypass the dirty-check guard so the post-save redirect isn't intercepted. bypassDirtyCheck = true; - goto(resolve('/storage/connections')); + goto(resolve('/settings/connections')); } } } @@ -67,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; }); @@ -112,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() { @@ -190,7 +153,6 @@

    {m.storage_connection_edit_name_hint()}

    - @@ -252,7 +214,6 @@ />
    - {#if $form.tls}
    @@ -351,16 +312,17 @@

    {$message}

    {/if} -
    + + + {/if} diff --git a/src/routes/(app)/storage/+error.svelte b/src/routes/(app)/storage/+error.svelte index bcc2ce6a..ff30d93b 100644 --- a/src/routes/(app)/storage/+error.svelte +++ b/src/routes/(app)/storage/+error.svelte @@ -2,16 +2,17 @@ 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. - const bucket = $derived( - page.params.bucket || - decodeURIComponent(page.url?.pathname?.split('/').filter(Boolean)[1] ?? '') - ); + const bucket = $derived(page.params.bucket ?? ''); + const connection = $derived(page.params.connection ?? ''); + + const prefixParts = $derived.by(() => { + const segments = page.url?.pathname?.split('/').filter(Boolean) ?? []; + return decodeURIComponent(segments.slice(4).join('/') || ''); + }); function bucketErrorMessage(status: number, name: string): string | null { if (!name) return null; @@ -24,43 +25,70 @@ const errorMessage = $derived(bucketErrorMessage(page.status, bucket)); -
    +
    +
    + +
    + - -
    - -

    - {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..0fa275bf 100644 --- a/src/routes/(app)/storage/+layout.server.ts +++ b/src/routes/(app)/storage/+layout.server.ts @@ -1,6 +1,12 @@ 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'; export const load: LayoutServerLoad = async ({ locals }) => { if (!storageBrowserEnabled) { @@ -9,7 +15,44 @@ 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 = JSON.parse(decrypt(row.encryptedPayload, storageEncryptionKey())) as { + host?: string; + port?: number; + }; + 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 20fde48e..ecabbe11 100644 --- a/src/routes/(app)/storage/+page.server.ts +++ b/src/routes/(app)/storage/+page.server.ts @@ -1,19 +1,59 @@ -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 { StorageConnectionSchema } from '$lib/storage/schemas.js'; -import { listBuckets } from '$lib/server/storage/service.js'; -import type { S3ConnectionConfig } from '$lib/server/storage/types.js'; +import { StorageConnectionSchema, ConnectionIdSchema } 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 { ConnectionMetadata, S3ConnectionConfig } from '$lib/server/storage/types.js'; +import * as m from '$lib/paraglide/messages.js'; export const load: PageServerLoad = async ({ locals }) => { + const log = locals.logger; const connectionForm = await superValidate( { 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 = JSON.parse(decrypt(row.encryptedPayload, storageEncryptionKey())) as { + host?: string; + port?: number; + }; + 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 = { @@ -29,7 +69,7 @@ export const actions: Actions = { const { type, host, port, tls, accessStyle, region, credentials } = form.data; if (type !== 's3') { - return message(form, 'HDFS connections are not yet supported', { status: 400 }); + return message(form, m.storage_connect_error_hdfs(), { status: 400 }); } const resolvedCredentials = @@ -46,20 +86,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, 'Could not connect — check the endpoint and credentials.', { - 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, 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..157bd586 100644 --- a/src/routes/(app)/storage/+page.svelte +++ b/src/routes/(app)/storage/+page.svelte @@ -1,23 +1,105 @@ -{#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..fe83f29c --- /dev/null +++ b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.svelte @@ -0,0 +1,114 @@ + + + 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..32a5efc1 --- /dev/null +++ b/src/routes/(app)/storage/browse/[connection]/[bucket]/[...prefix]/+page.ts @@ -0,0 +1,78 @@ +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 }) => { + const { connection, bucket, prefix } = data; + + const connectionId = connectionStore.activeConnectionId ?? data.activeConnectionId; + if (!connectionId) throw redirect(303, '/storage'); + if (browser && connectionHostname(connectionStore.activeConnection) !== connection) { + throw redirect(303, '/storage'); + } + + 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 534a4c56..00000000 --- a/src/routes/(app)/storage/connections/[id]/edit/+page.server.ts +++ /dev/null @@ -1,64 +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 { EditStorageConnectionSchema } from '$lib/storage/schemas.js'; -import { listBuckets } from '$lib/server/storage/service.js'; -import type { S3ConnectionConfig } from '$lib/server/storage/types.js'; - -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, 'HDFS connections are not yet supported', { 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, 'Could not connect — check the endpoint and credentials.', { - status: 400 - }); - } - } - - return message(form, 'ok'); - } -}; 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 f15982e7..418b5677 100644 --- a/src/routes/(app)/storage/page.server.test.ts +++ b/src/routes/(app)/storage/page.server.test.ts @@ -1,11 +1,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('$lib/server/storage/service.js', () => ({ - listBuckets: vi.fn() +const mockConnectionProvider = { listContainers: vi.fn() }; +vi.mock('$lib/server/storage/utils.js', () => ({ + getConnectionProvider: () => mockConnectionProvider })); vi.mock('$lib/storage/schemas.js', () => ({ - StorageConnectionSchema: {} // superValidate is also mocked + StorageConnectionSchema: {}, + ConnectionIdSchema: {} +})); + +const { mockUpdateSession } = vi.hoisted(() => ({ + mockUpdateSession: vi.fn().mockResolvedValue({}) +})); +vi.mock('$lib/server/auth.js', () => ({ + auth: { api: { updateSession: mockUpdateSession } } +})); + +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) })); vi.mock('sveltekit-superforms', () => ({ @@ -17,14 +35,46 @@ 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 }) }) + }) + } +})); + +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 { listBuckets } from '$lib/server/storage/service.js'; import { superValidate } from 'sveltekit-superforms'; function mockLocals() { return { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, user: { id: 'test-user' } }; } +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()); @@ -58,14 +108,7 @@ describe('storage page actions', () => { it('connect: returns message for non-s3 type', async () => { vi.mocked(superValidate).mockResolvedValue({ valid: true, - data: { - type: 'hdfs', - endpoint: '', - pathStyle: false, - region: '', - accessKeyId: '', - secretAccessKey: '' - } + data: { ...validFormData, type: 'hdfs' as const } } as unknown as Awaited>); const result = await actions.connect({ @@ -86,7 +129,7 @@ describe('storage page actions', () => { credentials: { accessKey: 'ak', secretKey: 'sk' } } } as unknown as Awaited>); - vi.mocked(listBuckets).mockRejectedValue(new Error('connection refused')); + mockConnectionProvider.listContainers.mockRejectedValue(new Error('connection refused')); const result = await actions.connect({ request: new Request('http://localhost', { method: 'POST' }), @@ -109,7 +152,8 @@ describe('storage page actions', () => { credentials: { accessKey: 'ak', secretKey: 'sk' } } } as unknown as Awaited>); - vi.mocked(listBuckets).mockResolvedValue(['b1']); + mockConnectionProvider.listContainers.mockResolvedValue(['b1']); + mockSaveConnection.mockResolvedValue('new-conn-id'); await expect( actions.connect({ @@ -119,13 +163,98 @@ describe('storage page actions', () => { ).rejects.toThrow(expect.objectContaining({ status: 303, location: '/storage' })); }); - it('disconnect: redirects', async () => { + it('disconnect: redirects to /storage', async () => { await expect( - actions.disconnect({ locals: mockLocals() } as unknown as Parameters< - typeof actions.disconnect - >[0]) - ).rejects.toThrow( - expect.objectContaining({ status: 303, location: '/storage?disconnected=1' }) + 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('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.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 4241f9fb..e7606c10 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -22,6 +22,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(); @@ -716,7 +717,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}
    @@ -775,8 +778,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()} @@ -884,18 +888,20 @@ {#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) })} {/if} {#if runner.currentTrinoQueryUrl} + + {m.trino_view_in_trino()} 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.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/setup-client.ts b/src/test/setup-client.ts index e01a15bc..dfd4d7ce 100644 --- a/src/test/setup-client.ts +++ b/src/test/setup-client.ts @@ -6,10 +6,18 @@ import { vi, beforeEach } from 'vitest'; // 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, + storageAutoConnectTimeoutMs: 15_000 })); // Prevent components from auto-submitting forms in browser tests. @@ -20,4 +28,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 7f115657..e4c97a1e 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', @@ -34,7 +38,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/**'], setupFiles: ['src/test/setup-client.ts'] } }, @@ -45,7 +49,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/**'] } } ] diff --git a/vitest.arch.config.ts b/vitest.arch.config.ts new file mode 100644 index 00000000..5a8fb6dd --- /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/**/*.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 + } +});